Merge branch 'main' into lhj - 충돌 해결 (메인 브랜치 코드 선택)

This commit is contained in:
leeheejin 2025-10-01 11:31:53 +09:00
commit 2a8841c6dc
59 changed files with 14678 additions and 3227 deletions

2
.gitignore vendored
View File

@ -290,3 +290,5 @@ uploads/
*.pptx *.pptx
*.hwp *.hwp
*.hwpx *.hwpx
claude.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,733 @@
# 🔐 Phase 1.5: 인증 및 관리자 서비스 Raw Query 전환 계획
## 📋 개요
Phase 2의 핵심 서비스 전환 전에 **인증 및 관리자 시스템**을 먼저 Raw Query로 전환하여 전체 시스템의 안정적인 기반을 구축합니다.
### 🎯 목표
- AuthService의 5개 Prisma 호출 제거
- AdminService의 3개 Prisma 호출 제거 (이미 Raw Query 사용 중)
- AdminController의 28개 Prisma 호출 제거
- 로그인 → 인증 → API 호출 전체 플로우 검증
### 📊 전환 대상
| 서비스 | Prisma 호출 수 | 복잡도 | 우선순위 |
|--------|----------------|--------|----------|
| AuthService | 5개 | 중간 | 🔴 최우선 |
| AdminService | 3개 | 낮음 (이미 Raw Query) | 🟢 확인만 필요 |
| AdminController | 28개 | 중간 | 🟡 2순위 |
---
## 🔍 AuthService 분석
### Prisma 사용 현황 (5개)
```typescript
// Line 21: loginPwdCheck() - 사용자 비밀번호 조회
const userInfo = await prisma.user_info.findUnique({
where: { user_id: userId },
select: { user_password: true },
});
// Line 82: insertLoginAccessLog() - 로그인 로그 기록
await prisma.$executeRaw`INSERT INTO LOGIN_ACCESS_LOG(...)`;
// Line 126: getUserInfo() - 사용자 정보 조회
const userInfo = await prisma.user_info.findUnique({
where: { user_id: userId },
select: { /* 20개 필드 */ },
});
// Line 157: getUserInfo() - 권한 정보 조회
const authInfo = await prisma.authority_sub_user.findMany({
where: { user_id: userId },
include: { authority_master: { select: { auth_name: true } } },
});
// Line 177: getUserInfo() - 회사 정보 조회
const companyInfo = await prisma.company_mng.findFirst({
where: { company_code: userInfo.company_code || "ILSHIN" },
select: { company_name: true },
});
```
### 핵심 메서드
1. **loginPwdCheck()** - 로그인 비밀번호 검증
- user_info 테이블 조회
- 비밀번호 암호화 비교
- 마스터 패스워드 체크
2. **insertLoginAccessLog()** - 로그인 이력 기록
- LOGIN_ACCESS_LOG 테이블 INSERT
- Raw Query 이미 사용 중 (유지)
3. **getUserInfo()** - 사용자 상세 정보 조회
- user_info 테이블 조회 (20개 필드)
- authority_sub_user + authority_master 조인 (권한)
- company_mng 테이블 조회 (회사명)
- PersonBean 타입 변환
4. **processLogin()** - 로그인 전체 프로세스
- 위 3개 메서드 조합
- JWT 토큰 생성
---
## 🛠️ 전환 계획
### Step 1: loginPwdCheck() 전환
**기존 Prisma 코드:**
```typescript
const userInfo = await prisma.user_info.findUnique({
where: { user_id: userId },
select: { user_password: true },
});
```
**새로운 Raw Query 코드:**
```typescript
import { query } from "../database/db";
const result = await query<{ user_password: string }>(
"SELECT user_password FROM user_info WHERE user_id = $1",
[userId]
);
const userInfo = result.length > 0 ? result[0] : null;
```
### Step 2: getUserInfo() 전환 (사용자 정보)
**기존 Prisma 코드:**
```typescript
const userInfo = await prisma.user_info.findUnique({
where: { user_id: userId },
select: {
sabun: true,
user_id: true,
user_name: true,
// ... 20개 필드
},
});
```
**새로운 Raw Query 코드:**
```typescript
const result = await query<{
sabun: string | null;
user_id: string;
user_name: string;
user_name_eng: string | null;
user_name_cn: string | null;
dept_code: string | null;
dept_name: string | null;
position_code: string | null;
position_name: string | null;
email: string | null;
tel: string | null;
cell_phone: string | null;
user_type: string | null;
user_type_name: string | null;
partner_objid: string | null;
company_code: string | null;
locale: string | null;
photo: Buffer | null;
}>(
`SELECT
sabun, user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
partner_objid, company_code, locale, photo
FROM user_info
WHERE user_id = $1`,
[userId]
);
const userInfo = result.length > 0 ? result[0] : null;
```
### Step 3: getUserInfo() 전환 (권한 정보)
**기존 Prisma 코드:**
```typescript
const authInfo = await prisma.authority_sub_user.findMany({
where: { user_id: userId },
include: {
authority_master: {
select: { auth_name: true },
},
},
});
const authNames = authInfo
.filter((auth: any) => auth.authority_master?.auth_name)
.map((auth: any) => auth.authority_master!.auth_name!)
.join(",");
```
**새로운 Raw Query 코드:**
```typescript
const authResult = await query<{ auth_name: string }>(
`SELECT am.auth_name
FROM authority_sub_user asu
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
WHERE asu.user_id = $1`,
[userId]
);
const authNames = authResult.map(row => row.auth_name).join(",");
```
### Step 4: getUserInfo() 전환 (회사 정보)
**기존 Prisma 코드:**
```typescript
const companyInfo = await prisma.company_mng.findFirst({
where: { company_code: userInfo.company_code || "ILSHIN" },
select: { company_name: true },
});
```
**새로운 Raw Query 코드:**
```typescript
const companyResult = await query<{ company_name: string }>(
"SELECT company_name FROM company_mng WHERE company_code = $1",
[userInfo.company_code || "ILSHIN"]
);
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
```
---
## 📝 완전 전환된 AuthService 코드
```typescript
import { query } from "../database/db";
import { JwtUtils } from "../utils/jwtUtils";
import { EncryptUtil } from "../utils/encryptUtil";
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
import { logger } from "../utils/logger";
export class AuthService {
/**
* 로그인 비밀번호 검증 (Raw Query 전환)
*/
static async loginPwdCheck(
userId: string,
password: string
): Promise<LoginResult> {
try {
// Raw Query로 사용자 비밀번호 조회
const result = await query<{ user_password: string }>(
"SELECT user_password FROM user_info WHERE user_id = $1",
[userId]
);
const userInfo = result.length > 0 ? result[0] : null;
if (userInfo && userInfo.user_password) {
const dbPassword = userInfo.user_password;
logger.info(`로그인 시도: ${userId}`);
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
// 마스터 패스워드 체크
if (password === "qlalfqjsgh11") {
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
return { loginResult: true };
}
// 비밀번호 검증
if (EncryptUtil.matches(password, dbPassword)) {
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
return { loginResult: true };
} else {
logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`);
return {
loginResult: false,
errorReason: "패스워드가 일치하지 않습니다.",
};
}
} else {
logger.warn(`사용자가 존재하지 않음: ${userId}`);
return {
loginResult: false,
errorReason: "사용자가 존재하지 않습니다.",
};
}
} catch (error) {
logger.error(
`로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
return {
loginResult: false,
errorReason: "로그인 처리 중 오류가 발생했습니다.",
};
}
}
/**
* 로그인 로그 기록 (이미 Raw Query 사용 - 유지)
*/
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
try {
await query(
`INSERT INTO LOGIN_ACCESS_LOG(
LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
) VALUES (
now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
)`,
[
logData.systemName,
logData.userId,
logData.loginResult,
logData.errorMessage || null,
logData.remoteAddr,
logData.recptnDt || null,
logData.recptnRsltDtl || null,
logData.recptnRslt || null,
logData.recptnRsltCd || null,
]
);
logger.info(
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
);
} catch (error) {
logger.error(
`로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
// 로그 기록 실패는 로그인 프로세스를 중단하지 않음
}
}
/**
* 사용자 정보 조회 (Raw Query 전환)
*/
static async getUserInfo(userId: string): Promise<PersonBean | null> {
try {
// 1. 사용자 기본 정보 조회
const userResult = await query<{
sabun: string | null;
user_id: string;
user_name: string;
user_name_eng: string | null;
user_name_cn: string | null;
dept_code: string | null;
dept_name: string | null;
position_code: string | null;
position_name: string | null;
email: string | null;
tel: string | null;
cell_phone: string | null;
user_type: string | null;
user_type_name: string | null;
partner_objid: string | null;
company_code: string | null;
locale: string | null;
photo: Buffer | null;
}>(
`SELECT
sabun, user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
partner_objid, company_code, locale, photo
FROM user_info
WHERE user_id = $1`,
[userId]
);
const userInfo = userResult.length > 0 ? userResult[0] : null;
if (!userInfo) {
return null;
}
// 2. 권한 정보 조회 (JOIN으로 최적화)
const authResult = await query<{ auth_name: string }>(
`SELECT am.auth_name
FROM authority_sub_user asu
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
WHERE asu.user_id = $1`,
[userId]
);
const authNames = authResult.map(row => row.auth_name).join(",");
// 3. 회사 정보 조회
const companyResult = await query<{ company_name: string }>(
"SELECT company_name FROM company_mng WHERE company_code = $1",
[userInfo.company_code || "ILSHIN"]
);
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
// PersonBean 형태로 변환
const personBean: PersonBean = {
userId: userInfo.user_id,
userName: userInfo.user_name || "",
userNameEng: userInfo.user_name_eng || undefined,
userNameCn: userInfo.user_name_cn || undefined,
deptCode: userInfo.dept_code || undefined,
deptName: userInfo.dept_name || undefined,
positionCode: userInfo.position_code || undefined,
positionName: userInfo.position_name || undefined,
email: userInfo.email || undefined,
tel: userInfo.tel || undefined,
cellPhone: userInfo.cell_phone || undefined,
userType: userInfo.user_type || undefined,
userTypeName: userInfo.user_type_name || undefined,
partnerObjid: userInfo.partner_objid || undefined,
authName: authNames || undefined,
companyCode: userInfo.company_code || "ILSHIN",
photo: userInfo.photo
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined,
locale: userInfo.locale || "KR",
};
logger.info(`사용자 정보 조회 완료: ${userId}`);
return personBean;
} catch (error) {
logger.error(
`사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
return null;
}
}
/**
* JWT 토큰으로 사용자 정보 조회
*/
static async getUserInfoFromToken(token: string): Promise<PersonBean | null> {
try {
const userInfo = JwtUtils.verifyToken(token);
return userInfo;
} catch (error) {
logger.error(
`토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
return null;
}
}
/**
* 로그인 프로세스 전체 처리
*/
static async processLogin(
userId: string,
password: string,
remoteAddr: string
): Promise<{
success: boolean;
userInfo?: PersonBean;
token?: string;
errorReason?: string;
}> {
try {
// 1. 로그인 검증
const loginResult = await this.loginPwdCheck(userId, password);
// 2. 로그 기록
const logData: LoginLogData = {
systemName: "PMS",
userId: userId,
loginResult: loginResult.loginResult,
errorMessage: loginResult.errorReason,
remoteAddr: remoteAddr,
};
await this.insertLoginAccessLog(logData);
if (loginResult.loginResult) {
// 3. 사용자 정보 조회
const userInfo = await this.getUserInfo(userId);
if (!userInfo) {
return {
success: false,
errorReason: "사용자 정보를 조회할 수 없습니다.",
};
}
// 4. JWT 토큰 생성
const token = JwtUtils.generateToken(userInfo);
logger.info(`로그인 성공: ${userId} (${remoteAddr})`);
return {
success: true,
userInfo,
token,
};
} else {
logger.warn(
`로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})`
);
return {
success: false,
errorReason: loginResult.errorReason,
};
}
} catch (error) {
logger.error(
`로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
return {
success: false,
errorReason: "로그인 처리 중 오류가 발생했습니다.",
};
}
}
/**
* 로그아웃 프로세스 처리
*/
static async processLogout(
userId: string,
remoteAddr: string
): Promise<void> {
try {
// 로그아웃 로그 기록
const logData: LoginLogData = {
systemName: "PMS",
userId: userId,
loginResult: false,
errorMessage: "로그아웃",
remoteAddr: remoteAddr,
};
await this.insertLoginAccessLog(logData);
logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`);
} catch (error) {
logger.error(
`로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}`
);
}
}
}
```
---
## 🧪 테스트 계획
### 단위 테스트
```typescript
// backend-node/src/tests/authService.test.ts
import { AuthService } from "../services/authService";
import { query } from "../database/db";
describe("AuthService Raw Query 전환 테스트", () => {
describe("loginPwdCheck", () => {
test("존재하는 사용자 로그인 성공", async () => {
const result = await AuthService.loginPwdCheck("testuser", "testpass");
expect(result.loginResult).toBe(true);
});
test("존재하지 않는 사용자 로그인 실패", async () => {
const result = await AuthService.loginPwdCheck("nonexistent", "password");
expect(result.loginResult).toBe(false);
expect(result.errorReason).toContain("존재하지 않습니다");
});
test("잘못된 비밀번호 로그인 실패", async () => {
const result = await AuthService.loginPwdCheck("testuser", "wrongpass");
expect(result.loginResult).toBe(false);
expect(result.errorReason).toContain("일치하지 않습니다");
});
test("마스터 패스워드 로그인 성공", async () => {
const result = await AuthService.loginPwdCheck("testuser", "qlalfqjsgh11");
expect(result.loginResult).toBe(true);
});
});
describe("getUserInfo", () => {
test("사용자 정보 조회 성공", async () => {
const userInfo = await AuthService.getUserInfo("testuser");
expect(userInfo).not.toBeNull();
expect(userInfo?.userId).toBe("testuser");
expect(userInfo?.userName).toBeDefined();
});
test("권한 정보 조회 성공", async () => {
const userInfo = await AuthService.getUserInfo("testuser");
expect(userInfo?.authName).toBeDefined();
});
test("존재하지 않는 사용자 조회 실패", async () => {
const userInfo = await AuthService.getUserInfo("nonexistent");
expect(userInfo).toBeNull();
});
});
describe("processLogin", () => {
test("전체 로그인 프로세스 성공", async () => {
const result = await AuthService.processLogin(
"testuser",
"testpass",
"127.0.0.1"
);
expect(result.success).toBe(true);
expect(result.token).toBeDefined();
expect(result.userInfo).toBeDefined();
});
test("로그인 실패 시 토큰 없음", async () => {
const result = await AuthService.processLogin(
"testuser",
"wrongpass",
"127.0.0.1"
);
expect(result.success).toBe(false);
expect(result.token).toBeUndefined();
expect(result.errorReason).toBeDefined();
});
});
describe("insertLoginAccessLog", () => {
test("로그인 로그 기록 성공", async () => {
await expect(
AuthService.insertLoginAccessLog({
systemName: "PMS",
userId: "testuser",
loginResult: true,
remoteAddr: "127.0.0.1",
})
).resolves.not.toThrow();
});
});
});
```
### 통합 테스트
```typescript
// backend-node/src/tests/integration/auth.integration.test.ts
import request from "supertest";
import app from "../../app";
describe("인증 시스템 통합 테스트", () => {
let authToken: string;
test("POST /api/auth/login - 로그인 성공", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: "testuser",
password: "testpass",
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.token).toBeDefined();
expect(response.body.userInfo).toBeDefined();
authToken = response.body.token;
});
test("GET /api/auth/verify - 토큰 검증 성공", async () => {
const response = await request(app)
.get("/api/auth/verify")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(response.body.valid).toBe(true);
expect(response.body.userInfo).toBeDefined();
});
test("GET /api/admin/menu - 인증된 사용자 메뉴 조회", async () => {
const response = await request(app)
.get("/api/admin/menu")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
test("POST /api/auth/logout - 로그아웃 성공", async () => {
await request(app)
.post("/api/auth/logout")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
});
});
```
---
## 📋 체크리스트
### AuthService 전환
- [ ] import 문 변경 (`prisma` → `query`)
- [ ] `loginPwdCheck()` 메서드 전환
- [ ] Prisma findUnique → Raw Query SELECT
- [ ] 타입 정의 추가
- [ ] 에러 처리 확인
- [ ] `insertLoginAccessLog()` 메서드 확인
- [ ] 이미 Raw Query 사용 중 (유지)
- [ ] 파라미터 바인딩 확인
- [ ] `getUserInfo()` 메서드 전환
- [ ] 사용자 정보 조회 Raw Query 전환
- [ ] 권한 정보 조회 Raw Query 전환 (JOIN 최적화)
- [ ] 회사 정보 조회 Raw Query 전환
- [ ] PersonBean 타입 변환 로직 유지
- [ ] 모든 메서드 타입 안전성 확인
- [ ] 단위 테스트 작성 및 통과
### AdminService 확인
- [ ] 현재 코드 확인 (이미 Raw Query 사용 중)
- [ ] WITH RECURSIVE 쿼리 동작 확인
- [ ] 다국어 번역 로직 확인
### AdminController 전환
- [ ] Prisma 사용 현황 파악 (28개 호출)
- [ ] 각 API 엔드포인트별 전환 계획 수립
- [ ] Raw Query로 전환
- [ ] 통합 테스트 작성
### 통합 테스트
- [ ] 로그인 → 토큰 발급 테스트
- [ ] 토큰 검증 → API 호출 테스트
- [ ] 권한 확인 → 메뉴 조회 테스트
- [ ] 로그아웃 테스트
- [ ] 에러 케이스 테스트
---
## 🎯 완료 기준
- ✅ AuthService의 모든 Prisma 호출 제거
- ✅ AdminService Raw Query 사용 확인
- ✅ AdminController Prisma 호출 제거
- ✅ 모든 단위 테스트 통과
- ✅ 통합 테스트 통과
- ✅ 로그인 → 인증 → API 호출 플로우 정상 동작
- ✅ 성능 저하 없음 (기존 대비 ±10% 이내)
- ✅ 에러 처리 및 로깅 정상 동작
---
## 📚 참고 문서
- [Phase 1 완료 가이드](backend-node/PHASE1_USAGE_GUIDE.md)
- [DatabaseManager 사용법](backend-node/src/database/db.ts)
- [QueryBuilder 사용법](backend-node/src/utils/queryBuilder.ts)
- [전체 마이그레이션 계획](PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md)
---
**작성일**: 2025-09-30
**예상 소요 시간**: 2-3일
**담당자**: 백엔드 개발팀

View File

@ -0,0 +1,428 @@
# 🗂️ Phase 2.2: TableManagementService Raw Query 전환 계획
## 📋 개요
TableManagementService는 **33개의 Prisma 호출**이 있습니다. 대부분(약 26개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 33개 모두를 `db.ts``query` 함수로 교체**해야 합니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ----------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/tableManagementService.ts` |
| 파일 크기 | 3,178 라인 |
| Prisma 호출 | 33개 ($queryRaw: 26개, ORM: 7개) |
| **현재 진행률** | **0/33 (0%)****전환 필요** |
| **전환 필요** | **33개 모두 전환 필요** (SQL은 이미 작성되어 있음) |
| 복잡도 | 중간 (SQL 작성은 완료, `query()` 함수로 교체만 필요) |
| 우선순위 | 🟡 중간 (Phase 2.2) |
### 🎯 전환 목표
- ✅ **33개 모든 Prisma 호출을 `db.ts``query()` 함수로 교체**
- 26개 `$queryRaw``query()` 또는 `queryOne()`
- 7개 ORM 메서드 → `query()` (SQL 새로 작성)
- 1개 `$transaction``transaction()`
- ✅ 트랜잭션 처리 정상 동작 확인
- ✅ 모든 단위 테스트 통과
- ✅ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (26개)
**현재 상태**: SQL은 이미 작성되어 있음 ✅
**전환 작업**: `prisma.$queryRaw``query()` 함수로 교체만 하면 됨
```typescript
// 기존
await prisma.$queryRaw`SELECT ...`;
await prisma.$queryRawUnsafe(sqlString, ...params);
// 전환 후
import { query } from "../database/db";
await query(`SELECT ...`);
await query(sqlString, params);
```
### 2. ORM 메서드 사용 (7개)
**현재 상태**: Prisma ORM 메서드 사용
**전환 작업**: SQL 작성 필요
#### 1. table_labels 관리 (2개)
```typescript
// Line 254: 테이블 라벨 UPSERT
await prisma.table_labels.upsert({
where: { table_name: tableName },
update: {},
create: { table_name, table_label, description }
});
// Line 437: 테이블 라벨 조회
await prisma.table_labels.findUnique({
where: { table_name: tableName },
select: { table_name, table_label, description, ... }
});
```
#### 2. column_labels 관리 (5개)
```typescript
// Line 323: 컬럼 라벨 UPSERT
await prisma.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: columnName
}
},
update: { column_label, input_type, ... },
create: { table_name, column_name, ... }
});
// Line 481: 컬럼 라벨 조회
await prisma.column_labels.findUnique({
where: {
table_name_column_name: {
table_name: tableName,
column_name: columnName
}
},
select: { id, table_name, column_name, ... }
});
// Line 567: 컬럼 존재 확인
await prisma.column_labels.findFirst({
where: { table_name, column_name }
});
// Line 586: 컬럼 라벨 업데이트
await prisma.column_labels.update({
where: { id: existingColumn.id },
data: { web_type, detail_settings, ... }
});
// Line 610: 컬럼 라벨 생성
await prisma.column_labels.create({
data: { table_name, column_name, web_type, ... }
});
// Line 1003: 파일 타입 컬럼 조회
await prisma.column_labels.findMany({
where: { table_name, web_type: 'file' },
select: { column_name }
});
// Line 1382: 컬럼 웹타입 정보 조회
await prisma.column_labels.findFirst({
where: { table_name, column_name },
select: { web_type, code_category, ... }
});
// Line 2690: 컬럼 라벨 UPSERT (복제)
await prisma.column_labels.upsert({
where: {
table_name_column_name: { table_name, column_name }
},
update: { column_label, web_type, ... },
create: { table_name, column_name, ... }
});
```
#### 3. attach_file_info 관리 (2개)
```typescript
// Line 914: 파일 정보 조회
await prisma.attach_file_info.findMany({
where: { target_objid, doc_type, status: 'ACTIVE' },
select: { objid, real_file_name, file_size, ... },
orderBy: { regdate: 'desc' }
});
// Line 959: 파일 경로로 파일 정보 조회
await prisma.attach_file_info.findFirst({
where: { file_path, status: 'ACTIVE' },
select: { objid, real_file_name, ... }
});
```
#### 4. 트랜잭션 (1개)
```typescript
// Line 391: 전체 컬럼 설정 일괄 업데이트
await prisma.$transaction(async (tx) => {
await this.insertTableIfNotExists(tableName);
for (const columnSetting of columnSettings) {
await this.updateColumnSettings(tableName, columnName, columnSetting);
}
});
```
---
## 📝 전환 예시
### 예시 1: table_labels UPSERT 전환
**기존 Prisma 코드:**
```typescript
await prisma.table_labels.upsert({
where: { table_name: tableName },
update: {},
create: {
table_name: tableName,
table_label: tableName,
description: "",
},
});
```
**새로운 Raw Query 코드:**
```typescript
import { query } from "../database/db";
await query(
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (table_name) DO NOTHING`,
[tableName, tableName, ""]
);
```
### 예시 2: column_labels UPSERT 전환
**기존 Prisma 코드:**
```typescript
await prisma.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: columnName,
},
},
update: {
column_label: settings.columnLabel,
input_type: settings.inputType,
detail_settings: settings.detailSettings,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: columnName,
column_label: settings.columnLabel,
input_type: settings.inputType,
detail_settings: settings.detailSettings,
},
});
```
**새로운 Raw Query 코드:**
```typescript
await query(
`INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
code_category = EXCLUDED.code_category,
code_value = EXCLUDED.code_value,
reference_table = EXCLUDED.reference_table,
reference_column = EXCLUDED.reference_column,
display_column = EXCLUDED.display_column,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = NOW()`,
[
tableName,
columnName,
settings.columnLabel,
settings.inputType,
settings.detailSettings,
settings.codeCategory,
settings.codeValue,
settings.referenceTable,
settings.referenceColumn,
settings.displayColumn,
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true,
]
);
```
### 예시 3: 트랜잭션 전환
**기존 Prisma 코드:**
```typescript
await prisma.$transaction(async (tx) => {
await this.insertTableIfNotExists(tableName);
for (const columnSetting of columnSettings) {
await this.updateColumnSettings(tableName, columnName, columnSetting);
}
});
```
**새로운 Raw Query 코드:**
```typescript
import { transaction } from "../database/db";
await transaction(async (client) => {
// 테이블 라벨 자동 추가
await client.query(
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (table_name) DO NOTHING`,
[tableName, tableName, ""]
);
// 각 컬럼 설정 업데이트
for (const columnSetting of columnSettings) {
const columnName = columnSetting.columnName;
if (columnName) {
await client.query(
`INSERT INTO column_labels (...)
VALUES (...)
ON CONFLICT (table_name, column_name) DO UPDATE SET ...`,
[...]
);
}
}
});
```
---
## 🧪 테스트 계획
### 단위 테스트 (10개)
```typescript
describe("TableManagementService Raw Query 전환 테스트", () => {
describe("insertTableIfNotExists", () => {
test("테이블 라벨 UPSERT 성공", async () => { ... });
test("중복 테이블 처리", async () => { ... });
});
describe("updateColumnSettings", () => {
test("컬럼 설정 UPSERT 성공", async () => { ... });
test("기존 컬럼 업데이트", async () => { ... });
});
describe("getTableLabels", () => {
test("테이블 라벨 조회 성공", async () => { ... });
});
describe("getColumnLabels", () => {
test("컬럼 라벨 조회 성공", async () => { ... });
});
describe("updateAllColumnSettings", () => {
test("일괄 업데이트 성공 (트랜잭션)", async () => { ... });
test("부분 실패 시 롤백", async () => { ... });
});
describe("getFileInfoByColumnAndTarget", () => {
test("파일 정보 조회 성공", async () => { ... });
});
});
```
### 통합 테스트 (5개 시나리오)
```typescript
describe("테이블 관리 통합 테스트", () => {
test("테이블 라벨 생성 → 조회 → 수정", async () => { ... });
test("컬럼 라벨 생성 → 조회 → 수정", async () => { ... });
test("컬럼 일괄 설정 업데이트", async () => { ... });
test("파일 정보 조회 및 보강", async () => { ... });
test("트랜잭션 롤백 테스트", async () => { ... });
});
```
---
## 📋 체크리스트
### 1단계: table_labels 전환 (2개 함수) ⏳ **진행 예정**
- [ ] `insertTableIfNotExists()` - UPSERT
- [ ] `getTableLabels()` - 조회
### 2단계: column_labels 전환 (5개 함수) ⏳ **진행 예정**
- [ ] `updateColumnSettings()` - UPSERT
- [ ] `getColumnLabels()` - 조회
- [ ] `updateColumnWebType()` - findFirst + update/create
- [ ] `getColumnWebTypeInfo()` - findFirst
- [ ] `updateColumnLabel()` - UPSERT (복제)
### 3단계: attach_file_info 전환 (2개 함수) ⏳ **진행 예정**
- [ ] `getFileInfoByColumnAndTarget()` - findMany
- [ ] `getFileInfoByPath()` - findFirst
### 4단계: 트랜잭션 전환 (1개 함수) ⏳ **진행 예정**
- [ ] `updateAllColumnSettings()` - 트랜잭션
### 5단계: 테스트 & 검증 ⏳ **진행 예정**
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (5개 시나리오)
- [ ] Prisma import 완전 제거 확인
- [ ] 성능 테스트
---
## 🎯 완료 기준
- [ ] **33개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] 26개 `$queryRaw``query()` 함수로 교체
- [ ] 7개 ORM 메서드 → `query()` 함수로 전환 (SQL 작성)
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **트랜잭션 정상 동작 확인**
- [ ] **에러 처리 및 롤백 정상 동작**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **모든 통합 테스트 작성 완료 (5개 시나리오)**
- [ ] **`import prisma` 완전 제거 및 `import { query, transaction } from "../database/db"` 사용**
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)**
---
## 💡 특이사항
### SQL은 이미 대부분 작성되어 있음
이 서비스는 이미 79%가 `$queryRaw`를 사용하고 있어, **SQL 작성은 완료**되었습니다:
- ✅ `information_schema` 조회: SQL 작성 완료 (`$queryRaw` 사용 중)
- ✅ 동적 테이블 쿼리: SQL 작성 완료 (`$queryRawUnsafe` 사용 중)
- ✅ DDL 실행: SQL 작성 완료 (`$executeRaw` 사용 중)
- ⏳ **전환 작업**: `prisma.$queryRaw``query()` 함수로 **단순 교체만 필요**
- ⏳ CRUD 작업: 7개만 SQL 새로 작성 필요
### UPSERT 패턴 중요
대부분의 전환이 UPSERT 패턴이므로 PostgreSQL의 `ON CONFLICT` 구문을 활용합니다.
---
**작성일**: 2025-09-30
**예상 소요 시간**: 1-1.5일 (SQL은 79% 작성 완료, 함수 교체 작업 필요)
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 2.2)
**상태**: ⏳ **진행 예정**
**특이사항**: SQL은 대부분 작성되어 있어 `prisma.$queryRaw``query()` 단순 교체 작업이 주요 작업

View File

@ -0,0 +1,736 @@
# 📊 Phase 2.3: DataflowService Raw Query 전환 계획
## 📋 개요
DataflowService는 **31개의 Prisma 호출**이 있는 핵심 서비스입니다. 테이블 간 관계 관리, 데이터플로우 다이어그램, 데이터 연결 브리지 등 복잡한 기능을 포함합니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ---------------------------------------------- |
| 파일 위치 | `backend-node/src/services/dataflowService.ts` |
| 파일 크기 | 1,170+ 라인 |
| Prisma 호출 | 0개 (전환 완료) |
| **현재 진행률** | **31/31 (100%)****완료** |
| 복잡도 | 매우 높음 (트랜잭션 + 복잡한 관계 관리) |
| 우선순위 | 🔴 최우선 (Phase 2.3) |
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
### 🎯 전환 목표
- ✅ 31개 Prisma 호출을 모두 Raw Query로 전환
- ✅ 트랜잭션 처리 정상 동작 확인
- ✅ 에러 처리 및 롤백 정상 동작
- ✅ 모든 단위 테스트 통과 (20개 이상)
- ✅ 통합 테스트 작성 완료
- ✅ Prisma import 완전 제거
---
## 🔍 Prisma 사용 현황 분석
### 1. 테이블 관계 관리 (Table Relationships) - 22개
#### 1.1 관계 생성 (3개)
```typescript
// Line 48: 최대 diagram_id 조회
await prisma.table_relationships.findFirst({
where: { company_code },
orderBy: { diagram_id: 'desc' }
});
// Line 64: 중복 관계 확인
await prisma.table_relationships.findFirst({
where: { diagram_id, source_table, target_table, relationship_type }
});
// Line 83: 새 관계 생성
await prisma.table_relationships.create({
data: { diagram_id, source_table, target_table, ... }
});
```
#### 1.2 관계 조회 (6개)
```typescript
// Line 128: 관계 목록 조회
await prisma.table_relationships.findMany({
where: whereCondition,
orderBy: { created_at: 'desc' }
});
// Line 164: 단일 관계 조회
await prisma.table_relationships.findFirst({
where: whereCondition
});
// Line 287: 회사별 관계 조회
await prisma.table_relationships.findMany({
where: { company_code, is_active: 'Y' },
orderBy: { diagram_id: 'asc' }
});
// Line 326: 테이블별 관계 조회
await prisma.table_relationships.findMany({
where: whereCondition,
orderBy: { relationship_type: 'asc' }
});
// Line 784: diagram_id별 관계 조회
await prisma.table_relationships.findMany({
where: whereCondition,
select: { diagram_id, diagram_name, source_table, ... }
});
// Line 883: 회사 코드로 전체 조회
await prisma.table_relationships.findMany({
where: { company_code, is_active: 'Y' }
});
```
#### 1.3 통계 조회 (3개)
```typescript
// Line 362: 전체 관계 수
await prisma.table_relationships.count({
where: whereCondition,
});
// Line 367: 관계 타입별 통계
await prisma.table_relationships.groupBy({
by: ["relationship_type"],
where: whereCondition,
_count: { relationship_id: true },
});
// Line 376: 연결 타입별 통계
await prisma.table_relationships.groupBy({
by: ["connection_type"],
where: whereCondition,
_count: { relationship_id: true },
});
```
#### 1.4 관계 수정/삭제 (5개)
```typescript
// Line 209: 관계 수정
await prisma.table_relationships.update({
where: { relationship_id },
data: { source_table, target_table, ... }
});
// Line 248: 소프트 삭제
await prisma.table_relationships.update({
where: { relationship_id },
data: { is_active: 'N', updated_at: new Date() }
});
// Line 936: 중복 diagram_name 확인
await prisma.table_relationships.findFirst({
where: { company_code, diagram_name, is_active: 'Y' }
});
// Line 953: 최대 diagram_id 조회 (복사용)
await prisma.table_relationships.findFirst({
where: { company_code },
orderBy: { diagram_id: 'desc' }
});
// Line 1015: 관계도 완전 삭제
await prisma.table_relationships.deleteMany({
where: { company_code, diagram_id, is_active: 'Y' }
});
```
#### 1.5 복잡한 조회 (5개)
```typescript
// Line 919: 원본 관계도 조회
await prisma.table_relationships.findMany({
where: { company_code, diagram_id: sourceDiagramId, is_active: "Y" },
});
// Line 1046: diagram_id로 모든 관계 조회
await prisma.table_relationships.findMany({
where: { diagram_id, is_active: "Y" },
orderBy: { created_at: "asc" },
});
// Line 1085: 특정 relationship_id의 diagram_id 찾기
await prisma.table_relationships.findFirst({
where: { relationship_id, company_code },
});
```
### 2. 데이터 연결 브리지 (Data Relationship Bridge) - 8개
#### 2.1 브리지 생성/수정 (4개)
```typescript
// Line 425: 브리지 생성
await prisma.data_relationship_bridge.create({
data: {
relationship_id,
source_record_id,
target_record_id,
...
}
});
// Line 554: 브리지 수정
await prisma.data_relationship_bridge.update({
where: whereCondition,
data: { target_record_id, ... }
});
// Line 595: 브리지 소프트 삭제
await prisma.data_relationship_bridge.update({
where: whereCondition,
data: { is_active: 'N', updated_at: new Date() }
});
// Line 637: 브리지 일괄 삭제
await prisma.data_relationship_bridge.updateMany({
where: whereCondition,
data: { is_active: 'N', updated_at: new Date() }
});
```
#### 2.2 브리지 조회 (4개)
```typescript
// Line 471: relationship_id로 브리지 조회
await prisma.data_relationship_bridge.findMany({
where: whereCondition,
orderBy: { created_at: "desc" },
});
// Line 512: 레코드별 브리지 조회
await prisma.data_relationship_bridge.findMany({
where: whereCondition,
orderBy: { created_at: "desc" },
});
```
### 3. Raw Query 사용 (이미 있음) - 1개
```typescript
// Line 673: 테이블 존재 확인
await prisma.$queryRaw`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ${tableName}
`;
```
### 4. 트랜잭션 사용 - 1개
```typescript
// Line 968: 관계도 복사 트랜잭션
await prisma.$transaction(
originalRelationships.map((rel) =>
prisma.table_relationships.create({
data: {
diagram_id: newDiagramId,
company_code: companyCode,
source_table: rel.source_table,
target_table: rel.target_table,
...
}
})
)
);
```
---
## 🛠️ 전환 전략
### 전략 1: 단계적 전환
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
2. **2단계**: 복잡한 조회 전환 (groupBy, count, 조건부 조회)
3. **3단계**: 트랜잭션 전환
4. **4단계**: Raw Query 개선
### 전략 2: 함수별 전환 우선순위
#### 🔴 최우선 (기본 CRUD)
- `createRelationship()` - Line 83
- `getRelationships()` - Line 128
- `getRelationshipById()` - Line 164
- `updateRelationship()` - Line 209
- `deleteRelationship()` - Line 248
#### 🟡 2순위 (브리지 관리)
- `createDataLink()` - Line 425
- `getLinkedData()` - Line 471
- `getLinkedDataByRecord()` - Line 512
- `updateDataLink()` - Line 554
- `deleteDataLink()` - Line 595
#### 🟢 3순위 (통계 & 조회)
- `getRelationshipStats()` - Line 362-376
- `getAllRelationshipsByCompany()` - Line 287
- `getRelationshipsByTable()` - Line 326
- `getDiagrams()` - Line 784
#### 🔵 4순위 (복잡한 기능)
- `copyDiagram()` - Line 968 (트랜잭션)
- `deleteDiagram()` - Line 1015
- `getRelationshipsForDiagram()` - Line 1046
---
## 📝 전환 예시
### 예시 1: createRelationship() 전환
**기존 Prisma 코드:**
```typescript
// Line 48: 최대 diagram_id 조회
const maxDiagramId = await prisma.table_relationships.findFirst({
where: { company_code: data.companyCode },
orderBy: { diagram_id: 'desc' }
});
// Line 64: 중복 관계 확인
const existingRelationship = await prisma.table_relationships.findFirst({
where: {
diagram_id: diagramId,
source_table: data.sourceTable,
target_table: data.targetTable,
relationship_type: data.relationshipType
}
});
// Line 83: 새 관계 생성
const relationship = await prisma.table_relationships.create({
data: {
diagram_id: diagramId,
company_code: data.companyCode,
diagram_name: data.diagramName,
source_table: data.sourceTable,
target_table: data.targetTable,
relationship_type: data.relationshipType,
...
}
});
```
**새로운 Raw Query 코드:**
```typescript
import { query } from "../database/db";
// 최대 diagram_id 조회
const maxDiagramResult = await query<{ diagram_id: number }>(
`SELECT diagram_id FROM table_relationships
WHERE company_code = $1
ORDER BY diagram_id DESC
LIMIT 1`,
[data.companyCode]
);
const diagramId =
data.diagramId ||
(maxDiagramResult.length > 0 ? maxDiagramResult[0].diagram_id + 1 : 1);
// 중복 관계 확인
const existingResult = await query<{ relationship_id: number }>(
`SELECT relationship_id FROM table_relationships
WHERE diagram_id = $1
AND source_table = $2
AND target_table = $3
AND relationship_type = $4
LIMIT 1`,
[diagramId, data.sourceTable, data.targetTable, data.relationshipType]
);
if (existingResult.length > 0) {
throw new Error("이미 존재하는 관계입니다.");
}
// 새 관계 생성
const [relationship] = await query<TableRelationship>(
`INSERT INTO table_relationships (
diagram_id, company_code, diagram_name, source_table, target_table,
relationship_type, connection_type, source_column, target_column,
is_active, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
RETURNING *`,
[
diagramId,
data.companyCode,
data.diagramName,
data.sourceTable,
data.targetTable,
data.relationshipType,
data.connectionType,
data.sourceColumn,
data.targetColumn,
]
);
```
### 예시 2: getRelationshipStats() 전환 (통계 조회)
**기존 Prisma 코드:**
```typescript
// Line 362: 전체 관계 수
const totalCount = await prisma.table_relationships.count({
where: whereCondition,
});
// Line 367: 관계 타입별 통계
const relationshipTypeStats = await prisma.table_relationships.groupBy({
by: ["relationship_type"],
where: whereCondition,
_count: { relationship_id: true },
});
// Line 376: 연결 타입별 통계
const connectionTypeStats = await prisma.table_relationships.groupBy({
by: ["connection_type"],
where: whereCondition,
_count: { relationship_id: true },
});
```
**새로운 Raw Query 코드:**
```typescript
// WHERE 조건 동적 생성
const whereParams: any[] = [];
let whereSQL = "";
let paramIndex = 1;
if (companyCode) {
whereSQL += `WHERE company_code = $${paramIndex}`;
whereParams.push(companyCode);
paramIndex++;
if (isActive !== undefined) {
whereSQL += ` AND is_active = $${paramIndex}`;
whereParams.push(isActive ? "Y" : "N");
paramIndex++;
}
}
// 전체 관계 수
const [totalResult] = await query<{ count: number }>(
`SELECT COUNT(*) as count
FROM table_relationships ${whereSQL}`,
whereParams
);
const totalCount = totalResult?.count || 0;
// 관계 타입별 통계
const relationshipTypeStats = await query<{
relationship_type: string;
count: number;
}>(
`SELECT relationship_type, COUNT(*) as count
FROM table_relationships ${whereSQL}
GROUP BY relationship_type
ORDER BY count DESC`,
whereParams
);
// 연결 타입별 통계
const connectionTypeStats = await query<{
connection_type: string;
count: number;
}>(
`SELECT connection_type, COUNT(*) as count
FROM table_relationships ${whereSQL}
GROUP BY connection_type
ORDER BY count DESC`,
whereParams
);
```
### 예시 3: copyDiagram() 트랜잭션 전환
**기존 Prisma 코드:**
```typescript
// Line 968: 트랜잭션으로 모든 관계 복사
const copiedRelationships = await prisma.$transaction(
originalRelationships.map((rel) =>
prisma.table_relationships.create({
data: {
diagram_id: newDiagramId,
company_code: companyCode,
diagram_name: newDiagramName,
source_table: rel.source_table,
target_table: rel.target_table,
...
}
})
)
);
```
**새로운 Raw Query 코드:**
```typescript
import { transaction } from "../database/db";
const copiedRelationships = await transaction(async (client) => {
const results: TableRelationship[] = [];
for (const rel of originalRelationships) {
const [copiedRel] = await client.query<TableRelationship>(
`INSERT INTO table_relationships (
diagram_id, company_code, diagram_name, source_table, target_table,
relationship_type, connection_type, source_column, target_column,
is_active, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
RETURNING *`,
[
newDiagramId,
companyCode,
newDiagramName,
rel.source_table,
rel.target_table,
rel.relationship_type,
rel.connection_type,
rel.source_column,
rel.target_column,
]
);
results.push(copiedRel);
}
return results;
});
```
---
## 🧪 테스트 계획
### 단위 테스트 (20개 이상)
```typescript
describe('DataflowService Raw Query 전환 테스트', () => {
describe('createRelationship', () => {
test('관계 생성 성공', async () => { ... });
test('중복 관계 에러', async () => { ... });
test('diagram_id 자동 생성', async () => { ... });
});
describe('getRelationships', () => {
test('전체 관계 조회 성공', async () => { ... });
test('회사별 필터링', async () => { ... });
test('diagram_id별 필터링', async () => { ... });
});
describe('getRelationshipStats', () => {
test('통계 조회 성공', async () => { ... });
test('관계 타입별 그룹화', async () => { ... });
test('연결 타입별 그룹화', async () => { ... });
});
describe('copyDiagram', () => {
test('관계도 복사 성공 (트랜잭션)', async () => { ... });
test('diagram_name 중복 에러', async () => { ... });
});
describe('createDataLink', () => {
test('데이터 연결 생성 성공', async () => { ... });
test('브리지 레코드 저장', async () => { ... });
});
describe('getLinkedData', () => {
test('연결된 데이터 조회', async () => { ... });
test('relationship_id별 필터링', async () => { ... });
});
});
```
### 통합 테스트 (7개 시나리오)
```typescript
describe('Dataflow 관리 통합 테스트', () => {
test('관계 생명주기 (생성 → 조회 → 수정 → 삭제)', async () => { ... });
test('관계도 복사 및 검증', async () => { ... });
test('데이터 연결 브리지 생성 및 조회', async () => { ... });
test('통계 정보 조회', async () => { ... });
test('테이블별 관계 조회', async () => { ... });
test('diagram_id별 관계 조회', async () => { ... });
test('관계도 완전 삭제', async () => { ... });
});
```
---
## 📋 체크리스트
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
- [x] `createTableRelationship()` - 관계 생성
- [x] `getTableRelationships()` - 관계 목록 조회
- [x] `getTableRelationship()` - 단일 관계 조회
- [x] `updateTableRelationship()` - 관계 수정
- [x] `deleteTableRelationship()` - 관계 삭제 (소프트)
- [x] `getRelationshipsByTable()` - 테이블별 조회
- [x] `getRelationshipsByConnectionType()` - 연결타입별 조회
- [x] `getDataFlowDiagrams()` - diagram_id별 그룹 조회
### 2단계: 브리지 관리 (6개 함수) ✅ **완료**
- [x] `createDataLink()` - 데이터 연결 생성
- [x] `getLinkedDataByRelationship()` - 관계별 연결 데이터 조회
- [x] `getLinkedDataByTable()` - 테이블별 연결 데이터 조회
- [x] `updateDataLink()` - 연결 수정
- [x] `deleteDataLink()` - 연결 삭제 (소프트)
- [x] `deleteAllLinkedDataByRelationship()` - 관계별 모든 연결 삭제
### 3단계: 통계 & 복잡한 조회 (4개 함수) ✅ **완료**
- [x] `getRelationshipStats()` - 통계 조회
- [x] count 쿼리 전환
- [x] groupBy 쿼리 전환 (관계 타입별)
- [x] groupBy 쿼리 전환 (연결 타입별)
- [x] `getTableData()` - 테이블 데이터 조회 (페이징)
- [x] `getDiagramRelationships()` - 관계도 관계 조회
- [x] `getDiagramRelationshipsByDiagramId()` - diagram_id별 관계 조회
### 4단계: 복잡한 기능 (3개 함수) ✅ **완료**
- [x] `copyDiagram()` - 관계도 복사 (트랜잭션)
- [x] `deleteDiagram()` - 관계도 완전 삭제
- [x] `getDiagramRelationshipsByRelationshipId()` - relationship_id로 조회
### 5단계: 테스트 & 검증 ⏳ **진행 필요**
- [ ] 단위 테스트 작성 (20개 이상)
- createTableRelationship, updateTableRelationship, deleteTableRelationship
- getTableRelationships, getTableRelationship
- createDataLink, getLinkedDataByRelationship
- getRelationshipStats
- copyDiagram
- [ ] 통합 테스트 작성 (7개 시나리오)
- 관계 생명주기 테스트
- 관계도 복사 테스트
- 데이터 브리지 테스트
- 통계 조회 테스트
- [x] Prisma import 완전 제거 확인
- [ ] 성능 테스트
---
## 🎯 완료 기준
- [x] **31개 Prisma 호출 모두 Raw Query로 전환 완료**
- [x] **모든 TypeScript 컴파일 오류 해결**
- [x] **트랜잭션 정상 동작 확인**
- [x] **에러 처리 및 롤백 정상 동작**
- [ ] **모든 단위 테스트 통과 (20개 이상)**
- [ ] **모든 통합 테스트 작성 완료 (7개 시나리오)**
- [x] **Prisma import 완전 제거**
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)**
---
## 🎯 주요 기술적 도전 과제
### 1. groupBy 쿼리 전환
**문제**: Prisma의 `groupBy`를 Raw Query로 전환
**해결**: PostgreSQL의 `GROUP BY` 및 집계 함수 사용
```sql
SELECT relationship_type, COUNT(*) as count
FROM table_relationships
WHERE company_code = $1 AND is_active = 'Y'
GROUP BY relationship_type
ORDER BY count DESC
```
### 2. 트랜잭션 배열 처리
**문제**: Prisma의 `$transaction([...])` 배열 방식을 Raw Query로 전환
**해결**: `transaction` 함수 내에서 순차 실행
```typescript
await transaction(async (client) => {
const results = [];
for (const item of items) {
const result = await client.query(...);
results.push(result);
}
return results;
});
```
### 3. 동적 WHERE 조건 생성
**문제**: 다양한 필터 조건을 동적으로 구성
**해결**: 조건부 파라미터 인덱스 관리
```typescript
const whereParams: any[] = [];
const whereConditions: string[] = [];
let paramIndex = 1;
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex++}`);
whereParams.push(companyCode);
}
if (diagramId) {
whereConditions.push(`diagram_id = $${paramIndex++}`);
whereParams.push(diagramId);
}
const whereSQL =
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
```
---
## 📊 전환 완료 요약
### ✅ 성공적으로 전환된 항목
1. **기본 CRUD (8개)**: 모든 테이블 관계 CRUD 작업을 Raw Query로 전환
2. **브리지 관리 (6개)**: 데이터 연결 브리지의 모든 작업 전환
3. **통계 & 조회 (4개)**: COUNT, GROUP BY 등 복잡한 통계 쿼리 전환
4. **복잡한 기능 (3개)**: 트랜잭션 기반 관계도 복사 등 고급 기능 전환
### 🔧 주요 기술적 해결 사항
1. **트랜잭션 처리**: `transaction()` 함수 내에서 `client.query().rows` 사용
2. **동적 WHERE 조건**: 파라미터 인덱스를 동적으로 관리하여 유연한 쿼리 생성
3. **GROUP BY 전환**: Prisma의 `groupBy`를 PostgreSQL의 네이티브 GROUP BY로 전환
4. **타입 안전성**: 모든 쿼리 결과에 TypeScript 타입 지정
### 📈 다음 단계
- [ ] 단위 테스트 작성 및 실행
- [ ] 통합 테스트 시나리오 구현
- [ ] 성능 벤치마크 테스트
- [ ] 프로덕션 배포 준비
---
**작성일**: 2025-09-30
**완료일**: 2025-10-01
**소요 시간**: 1일
**담당자**: 백엔드 개발팀
**우선순위**: 🔴 최우선 (Phase 2.3)
**상태**: ✅ **전환 완료** (테스트 필요)

View File

@ -0,0 +1,230 @@
# 📝 Phase 2.4: DynamicFormService Raw Query 전환 계획
## 📋 개요
DynamicFormService는 **13개의 Prisma 호출**이 있습니다. 대부분(약 11개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 13개 모두를 `db.ts``query` 함수로 교체**해야 합니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/dynamicFormService.ts` |
| 파일 크기 | 1,213 라인 |
| Prisma 호출 | 0개 (전환 완료) |
| **현재 진행률** | **13/13 (100%)****완료** |
| **전환 상태** | **Raw Query로 전환 완료** |
| 복잡도 | 낮음 (SQL 작성 완료 → `query()` 함수로 교체 완료) |
| 우선순위 | 🟢 낮음 (Phase 2.4) |
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
### 🎯 전환 목표
- ✅ **13개 모든 Prisma 호출을 `db.ts``query()` 함수로 교체**
- 11개 `$queryRaw``query()` 함수로 교체
- 2개 ORM 메서드 → `query()` (SQL 새로 작성)
- ✅ 모든 단위 테스트 통과
- ✅ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (11개)
**현재 상태**: SQL은 이미 작성되어 있음 ✅
**전환 작업**: `prisma.$queryRaw``query()` 함수로 교체만 하면 됨
```typescript
// 기존
await prisma.$queryRaw<Array<{ column_name; data_type }>>`...`;
await prisma.$queryRawUnsafe(upsertQuery, ...values);
// 전환 후
import { query } from "../database/db";
await query<Array<{ column_name: string; data_type: string }>>(`...`);
await query(upsertQuery, values);
```
### 2. ORM 메서드 사용 (2개)
**현재 상태**: Prisma ORM 메서드 사용
**전환 작업**: SQL 작성 필요
#### 1. dynamic_form_data 조회 (1개)
```typescript
// Line 867: 폼 데이터 조회
const result = await prisma.dynamic_form_data.findUnique({
where: { id },
select: { data: true },
});
```
#### 2. screen_layouts 조회 (1개)
```typescript
// Line 1101: 화면 레이아웃 조회
const screenLayouts = await prisma.screen_layouts.findMany({
where: {
screen_id: screenId,
component_type: "widget",
},
select: {
component_id: true,
properties: true,
},
});
```
---
## 📝 전환 예시
### 예시 1: dynamic_form_data 조회 전환
**기존 Prisma 코드:**
```typescript
const result = await prisma.dynamic_form_data.findUnique({
where: { id },
select: { data: true },
});
```
**새로운 Raw Query 코드:**
```typescript
import { queryOne } from "../database/db";
const result = await queryOne<{ data: any }>(
`SELECT data FROM dynamic_form_data WHERE id = $1`,
[id]
);
```
### 예시 2: screen_layouts 조회 전환
**기존 Prisma 코드:**
```typescript
const screenLayouts = await prisma.screen_layouts.findMany({
where: {
screen_id: screenId,
component_type: "widget",
},
select: {
component_id: true,
properties: true,
},
});
```
**새로운 Raw Query 코드:**
```typescript
import { query } from "../database/db";
const screenLayouts = await query<{
component_id: string;
properties: any;
}>(
`SELECT component_id, properties
FROM screen_layouts
WHERE screen_id = $1 AND component_type = $2`,
[screenId, "widget"]
);
```
---
## 🧪 테스트 계획
### 단위 테스트 (5개)
```typescript
describe("DynamicFormService Raw Query 전환 테스트", () => {
describe("getFormDataById", () => {
test("폼 데이터 조회 성공", async () => { ... });
test("존재하지 않는 데이터", async () => { ... });
});
describe("getScreenLayoutsForControl", () => {
test("화면 레이아웃 조회 성공", async () => { ... });
test("widget 타입만 필터링", async () => { ... });
test("빈 결과 처리", async () => { ... });
});
});
```
### 통합 테스트 (3개 시나리오)
```typescript
describe("동적 폼 통합 테스트", () => {
test("폼 데이터 UPSERT → 조회", async () => { ... });
test("폼 데이터 업데이트 → 조회", async () => { ... });
test("화면 레이아웃 조회 → 제어 설정 확인", async () => { ... });
});
```
---
## 📋 전환 완료 내역
### ✅ 전환된 함수들 (13개 Raw Query 호출)
1. **getTableColumnInfo()** - 컬럼 정보 조회
2. **getPrimaryKeyColumns()** - 기본 키 조회
3. **getNotNullColumns()** - NOT NULL 컬럼 조회
4. **upsertFormData()** - UPSERT 실행
5. **partialUpdateFormData()** - 부분 업데이트
6. **updateFormData()** - 전체 업데이트
7. **deleteFormData()** - 데이터 삭제
8. **getFormDataById()** - 폼 데이터 조회
9. **getTableColumns()** - 테이블 컬럼 조회
10. **getTablePrimaryKeys()** - 기본 키 조회
11. **getScreenLayoutsForControl()** - 화면 레이아웃 조회
### 🔧 주요 기술적 해결 사항
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
2. **동적 UPSERT 쿼리**: PostgreSQL ON CONFLICT 구문 사용
3. **부분 업데이트**: 동적 SET 절 생성
4. **타입 변환**: PostgreSQL 타입 자동 변환 로직 유지
## 📋 체크리스트
### 1단계: ORM 호출 전환 ✅ **완료**
- [x] `getFormDataById()` - queryOne 전환
- [x] `getScreenLayoutsForControl()` - query 전환
- [x] 모든 Raw Query 함수 전환
### 2단계: 테스트 & 검증 ⏳ **진행 예정**
- [ ] 단위 테스트 작성 (5개)
- [ ] 통합 테스트 작성 (3개 시나리오)
- [x] Prisma import 완전 제거 확인 ✅
- [ ] 성능 테스트
---
## 🎯 완료 기준
- [x] **13개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [x] 11개 `$queryRaw``query()` 함수로 교체 ✅
- [x] 2개 ORM 메서드 → `query()` 함수로 전환 ✅
- [x] **모든 TypeScript 컴파일 오류 해결**
- [x] **`import prisma` 완전 제거** ✅
- [ ] **모든 단위 테스트 통과 (5개)**
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)**
- [ ] **성능 저하 없음**
---
**작성일**: 2025-09-30
**완료일**: 2025-10-01
**소요 시간**: 완료됨 (이전에 전환)
**담당자**: 백엔드 개발팀
**우선순위**: 🟢 낮음 (Phase 2.4)
**상태**: ✅ **전환 완료** (테스트 필요)
**특이사항**: SQL은 이미 작성되어 있었고, `query()` 함수로 교체 완료

View File

@ -0,0 +1,125 @@
# 🔌 Phase 2.5: ExternalDbConnectionService Raw Query 전환 계획
## 📋 개요
ExternalDbConnectionService는 **15개의 Prisma 호출**이 있으며, 외부 데이터베이스 연결 정보를 관리하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ---------------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/externalDbConnectionService.ts` |
| 파일 크기 | 1,100+ 라인 |
| Prisma 호출 | 0개 (전환 완료) |
| **현재 진행률** | **15/15 (100%)****완료** |
| 복잡도 | 중간 (CRUD + 연결 테스트) |
| 우선순위 | 🟡 중간 (Phase 2.5) |
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
### 🎯 전환 목표
- ✅ 15개 Prisma 호출을 모두 Raw Query로 전환
- ✅ 민감 정보 암호화 처리 유지
- ✅ 연결 테스트 로직 정상 동작
- ✅ 모든 단위 테스트 통과
---
## 🔍 주요 기능
### 1. 외부 DB 연결 정보 CRUD
- 생성, 조회, 수정, 삭제
- 연결 정보 암호화/복호화
### 2. 연결 테스트
- MySQL, PostgreSQL, MSSQL, Oracle 연결 테스트
### 3. 연결 정보 관리
- 회사별 연결 정보 조회
- 활성/비활성 상태 관리
---
## 📝 예상 전환 패턴
### CRUD 작업
```typescript
// 생성
await query(
`INSERT INTO external_db_connections
(connection_name, db_type, host, port, database_name, username, password, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[...]
);
// 조회
await query(
`SELECT * FROM external_db_connections
WHERE company_code = $1 AND is_active = 'Y'`,
[companyCode]
);
// 수정
await query(
`UPDATE external_db_connections
SET connection_name = $1, host = $2, ...
WHERE connection_id = $2`,
[...]
);
// 삭제 (소프트)
await query(
`UPDATE external_db_connections
SET is_active = 'N'
WHERE connection_id = $1`,
[connectionId]
);
```
---
## 📋 전환 완료 내역
### ✅ 전환된 함수들 (15개 Prisma 호출)
1. **getConnections()** - 동적 WHERE 조건 생성으로 전환
2. **getConnectionsGroupedByType()** - DB 타입 카테고리 조회
3. **getConnectionById()** - 단일 연결 조회 (비밀번호 마스킹)
4. **getConnectionByIdWithPassword()** - 비밀번호 포함 조회
5. **createConnection()** - 새 연결 생성 + 중복 확인
6. **updateConnection()** - 동적 필드 업데이트
7. **deleteConnection()** - 물리 삭제
8. **testConnectionById()** - 연결 테스트용 조회
9. **getDecryptedPassword()** - 비밀번호 복호화용 조회
10. **executeQuery()** - 쿼리 실행용 조회
11. **getTables()** - 테이블 목록 조회용
### 🔧 주요 기술적 해결 사항
1. **동적 WHERE 조건 생성**: 필터 조건에 따라 동적으로 SQL 생성
2. **동적 UPDATE 쿼리**: 변경된 필드만 업데이트하도록 구현
3. **ILIKE 검색**: 대소문자 구분 없는 검색 지원
4. **암호화 로직 유지**: PasswordEncryption 클래스와 통합 유지
## 🎯 완료 기준
- [x] **15개 Prisma 호출 모두 Raw Query로 전환**
- [x] **암호화/복호화 로직 정상 동작**
- [x] **연결 테스트 정상 동작**
- [ ] **모든 단위 테스트 통과 (10개 이상)**
- [x] **Prisma import 완전 제거**
- [x] **TypeScript 컴파일 성공**
---
**작성일**: 2025-09-30
**완료일**: 2025-10-01
**소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 2.5)
**상태**: ✅ **전환 완료** (테스트 필요)

View File

@ -0,0 +1,225 @@
# 🎮 Phase 2.6: DataflowControlService Raw Query 전환 계획
## 📋 개요
DataflowControlService는 **6개의 Prisma 호출**이 있으며, 데이터플로우 제어 및 실행을 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ----------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/dataflowControlService.ts` |
| 파일 크기 | 1,100+ 라인 |
| Prisma 호출 | 0개 (전환 완료) |
| **현재 진행률** | **6/6 (100%)****완료** |
| 복잡도 | 높음 (복잡한 비즈니스 로직) |
| 우선순위 | 🟡 중간 (Phase 2.6) |
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
### 🎯 전환 목표
- ✅ **6개 모든 Prisma 호출을 `db.ts``query()` 함수로 교체**
- ✅ 복잡한 비즈니스 로직 정상 동작 확인
- ✅ 모든 단위 테스트 통과
- ✅ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 기능
1. **데이터플로우 실행 관리**
- 관계 기반 데이터 조회 및 저장
- 조건부 실행 로직
2. **트랜잭션 처리**
- 여러 테이블에 걸친 데이터 처리
3. **데이터 변환 및 매핑**
- 소스-타겟 데이터 변환
---
## 📝 전환 계획
### 1단계: 기본 조회 전환 (2개 함수)
**함수 목록**:
- `getRelationshipById()` - 관계 정보 조회
- `getDataflowConfig()` - 데이터플로우 설정 조회
### 2단계: 데이터 실행 로직 전환 (2개 함수)
**함수 목록**:
- `executeDataflow()` - 데이터플로우 실행
- `validateDataflow()` - 데이터플로우 검증
### 3단계: 복잡한 기능 - 트랜잭션 (2개 함수)
**함수 목록**:
- `executeWithTransaction()` - 트랜잭션 내 실행
- `rollbackOnError()` - 에러 시 롤백
---
## 💻 전환 예시
### 예시 1: 관계 정보 조회
```typescript
// 기존 Prisma
const relationship = await prisma.table_relationship.findUnique({
where: { relationship_id: relationshipId },
include: {
source_table: true,
target_table: true,
},
});
// 전환 후
import { query } from "../database/db";
const relationship = await query<TableRelationship>(
`SELECT
tr.*,
st.table_name as source_table_name,
tt.table_name as target_table_name
FROM table_relationship tr
LEFT JOIN table_labels st ON tr.source_table_id = st.table_id
LEFT JOIN table_labels tt ON tr.target_table_id = tt.table_id
WHERE tr.relationship_id = $1`,
[relationshipId]
);
```
### 예시 2: 트랜잭션 내 실행
```typescript
// 기존 Prisma
await prisma.$transaction(async (tx) => {
// 소스 데이터 조회
const sourceData = await tx.dynamic_form_data.findMany(...);
// 타겟 데이터 저장
await tx.dynamic_form_data.createMany(...);
// 실행 로그 저장
await tx.dataflow_execution_log.create(...);
});
// 전환 후
import { transaction } from "../database/db";
await transaction(async (client) => {
// 소스 데이터 조회
const sourceData = await client.query(
`SELECT * FROM dynamic_form_data WHERE ...`,
[...]
);
// 타겟 데이터 저장
await client.query(
`INSERT INTO dynamic_form_data (...) VALUES (...)`,
[...]
);
// 실행 로그 저장
await client.query(
`INSERT INTO dataflow_execution_log (...) VALUES (...)`,
[...]
);
});
```
---
## ✅ 5단계: 테스트 & 검증
### 단위 테스트 (10개)
- [ ] getRelationshipById - 관계 정보 조회
- [ ] getDataflowConfig - 설정 조회
- [ ] executeDataflow - 데이터플로우 실행
- [ ] validateDataflow - 검증
- [ ] executeWithTransaction - 트랜잭션 실행
- [ ] rollbackOnError - 에러 처리
- [ ] transformData - 데이터 변환
- [ ] mapSourceToTarget - 필드 매핑
- [ ] applyConditions - 조건 적용
- [ ] logExecution - 실행 로그
### 통합 테스트 (4개 시나리오)
1. **데이터플로우 실행 시나리오**
- 관계 조회 → 데이터 실행 → 로그 저장
2. **트랜잭션 테스트**
- 여러 테이블 동시 처리
- 에러 발생 시 롤백
3. **조건부 실행 테스트**
- 조건에 따른 데이터 처리
4. **데이터 변환 테스트**
- 소스-타겟 데이터 매핑
---
## 📋 전환 완료 내역
### ✅ 전환된 함수들 (6개 Prisma 호출)
1. **executeDataflowControl()** - 관계도 정보 조회 (findUnique → queryOne)
2. **evaluateActionConditions()** - 대상 테이블 조건 확인 ($queryRawUnsafe → query)
3. **executeInsertAction()** - INSERT 실행 ($executeRawUnsafe → query)
4. **executeUpdateAction()** - UPDATE 실행 ($executeRawUnsafe → query)
5. **executeDeleteAction()** - DELETE 실행 ($executeRawUnsafe → query)
6. **checkColumnExists()** - 컬럼 존재 확인 ($queryRawUnsafe → query)
### 🔧 주요 기술적 해결 사항
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
2. **동적 테이블 쿼리 전환**: `$queryRawUnsafe` / `$executeRawUnsafe``query()`
3. **파라미터 바인딩 수정**: MySQL `?` → PostgreSQL `$1, $2...`
4. **복잡한 비즈니스 로직 유지**: 조건부 실행, 다중 커넥션, 에러 처리
## 🎯 완료 기준
- [x] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [x] **모든 TypeScript 컴파일 오류 해결**
- [x] **`import prisma` 완전 제거** ✅
- [ ] **트랜잭션 정상 동작 확인**
- [ ] **복잡한 비즈니스 로직 정상 동작**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **모든 통합 테스트 작성 완료 (4개 시나리오)**
- [ ] **성능 저하 없음**
---
## 💡 특이사항
### 복잡한 비즈니스 로직
이 서비스는 데이터플로우 제어라는 복잡한 비즈니스 로직을 처리합니다:
- 조건부 실행 로직
- 데이터 변환 및 매핑
- 트랜잭션 관리
- 에러 처리 및 롤백
### 성능 최적화 중요
데이터플로우 실행은 대량의 데이터를 처리할 수 있으므로:
- 배치 처리 고려
- 인덱스 활용
- 쿼리 최적화
---
**작성일**: 2025-09-30
**완료일**: 2025-10-01
**소요 시간**: 30분
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 2.6)
**상태**: ✅ **전환 완료** (테스트 필요)
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요

View File

@ -0,0 +1,175 @@
# 🔧 Phase 2.7: DDLExecutionService Raw Query 전환 계획
## 📋 개요
DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definition Language) 실행 및 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | -------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
| 파일 크기 | 400+ 라인 |
| Prisma 호출 | 4개 |
| **현재 진행률** | **6/6 (100%)****완료** |
| 복잡도 | 중간 (DDL 실행 + 로그 관리) |
| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) |
### 🎯 전환 목표
- ✅ **4개 모든 Prisma 호출을 `db.ts``query()` 함수로 교체**
- ✅ DDL 실행 정상 동작 확인
- ✅ 모든 단위 테스트 통과
- ✅ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 기능
1. **DDL 실행**
- CREATE TABLE, ALTER TABLE, DROP TABLE
- CREATE INDEX, DROP INDEX
2. **실행 로그 관리**
- DDL 실행 이력 저장
- 에러 로그 관리
3. **롤백 지원**
- DDL 롤백 SQL 생성 및 실행
---
## 📝 전환 계획
### 1단계: DDL 실행 전환 (2개 함수)
**함수 목록**:
- `executeDDL()` - DDL 실행
- `validateDDL()` - DDL 문법 검증
### 2단계: 로그 관리 전환 (2개 함수)
**함수 목록**:
- `saveDDLLog()` - 실행 로그 저장
- `getDDLHistory()` - 실행 이력 조회
---
## 💻 전환 예시
### 예시 1: DDL 실행 및 로그 저장
```typescript
// 기존 Prisma
await prisma.$executeRawUnsafe(ddlQuery);
await prisma.ddl_execution_log.create({
data: {
ddl_statement: ddlQuery,
execution_status: "SUCCESS",
executed_by: userId,
},
});
// 전환 후
import { query } from "../database/db";
await query(ddlQuery);
await query(
`INSERT INTO ddl_execution_log
(ddl_statement, execution_status, executed_by, executed_date)
VALUES ($1, $2, $3, $4)`,
[ddlQuery, "SUCCESS", userId, new Date()]
);
```
### 예시 2: DDL 실행 이력 조회
```typescript
// 기존 Prisma
const history = await prisma.ddl_execution_log.findMany({
where: {
company_code: companyCode,
execution_status: "SUCCESS",
},
orderBy: { executed_date: "desc" },
take: 50,
});
// 전환 후
import { query } from "../database/db";
const history = await query<DDLLog[]>(
`SELECT * FROM ddl_execution_log
WHERE company_code = $1
AND execution_status = $2
ORDER BY executed_date DESC
LIMIT $3`,
[companyCode, "SUCCESS", 50]
);
```
---
## ✅ 3단계: 테스트 & 검증
### 단위 테스트 (8개)
- [ ] executeDDL - CREATE TABLE
- [ ] executeDDL - ALTER TABLE
- [ ] executeDDL - DROP TABLE
- [ ] executeDDL - CREATE INDEX
- [ ] validateDDL - 문법 검증
- [ ] saveDDLLog - 로그 저장
- [ ] getDDLHistory - 이력 조회
- [ ] rollbackDDL - DDL 롤백
### 통합 테스트 (3개 시나리오)
1. **테이블 생성 → 로그 저장 → 이력 조회**
2. **DDL 실행 실패 → 에러 로그 저장**
3. **DDL 롤백 테스트**
---
## 🎯 완료 기준
- [ ] **4개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **DDL 실행 정상 동작 확인**
- [ ] **모든 단위 테스트 통과 (8개)**
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)**
- [ ] **`import prisma` 완전 제거 및 `import { query } from "../database/db"` 사용**
- [ ] **성능 저하 없음**
---
## 💡 특이사항
### DDL 실행의 위험성
DDL은 데이터베이스 스키마를 변경하므로 매우 신중하게 처리해야 합니다:
- 실행 전 검증 필수
- 롤백 SQL 자동 생성
- 실행 이력 철저히 관리
### 트랜잭션 지원 제한
PostgreSQL에서 일부 DDL은 트랜잭션을 지원하지만, 일부는 자동 커밋됩니다:
- CREATE TABLE: 트랜잭션 지원 ✅
- DROP TABLE: 트랜잭션 지원 ✅
- CREATE INDEX CONCURRENTLY: 트랜잭션 미지원 ❌
---
**작성일**: 2025-09-30
**예상 소요 시간**: 0.5일
**담당자**: 백엔드 개발팀
**우선순위**: 🟢 낮음 (Phase 2.7)
**상태**: ⏳ **진행 예정**
**특이사항**: DDL 실행의 특성상 신중한 테스트 필요

View File

@ -0,0 +1,566 @@
# 🖥️ Phase 2.1: ScreenManagementService Raw Query 전환 계획
## 📋 개요
ScreenManagementService는 **46개의 Prisma 호출**이 있는 가장 복잡한 서비스입니다. 화면 정의, 레이아웃, 메뉴 할당, 템플릿 등 다양한 기능을 포함합니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ------------------------------------------------------ |
| 파일 위치 | `backend-node/src/services/screenManagementService.ts` |
| 파일 크기 | 1,700+ 라인 |
| Prisma 호출 | 46개 |
| **현재 진행률** | **46/46 (100%)****완료** |
| 복잡도 | 매우 높음 |
| 우선순위 | 🔴 최우선 |
### 🎯 전환 현황 (2025-09-30 업데이트)
- ✅ **Stage 1 완료**: 기본 CRUD (8개 함수) - Commit: 13c1bc4, 0e8d1d4
- ✅ **Stage 2 완료**: 레이아웃 관리 (2개 함수, 4 Prisma 호출) - Commit: 67dced7
- ✅ **Stage 3 완료**: 템플릿 & 메뉴 관리 (5개 함수) - Commit: 74351e8
- ✅ **Stage 4 완료**: 복잡한 기능 (트랜잭션) - **모든 46개 Prisma 호출 전환 완료**
---
## 🔍 Prisma 사용 현황 분석
### 1. 화면 정의 관리 (Screen Definitions) - 18개
```typescript
// Line 53: 화면 코드 중복 확인
await prisma.screen_definitions.findFirst({ where: { screen_code, is_active: { not: "D" } } })
// Line 70: 화면 생성
await prisma.screen_definitions.create({ data: { ... } })
// Line 99: 화면 목록 조회 (페이징)
await prisma.screen_definitions.findMany({ where, skip, take, orderBy })
// Line 105: 화면 총 개수
await prisma.screen_definitions.count({ where })
// Line 166: 전체 화면 목록
await prisma.screen_definitions.findMany({ where })
// Line 178: 화면 코드로 조회
await prisma.screen_definitions.findFirst({ where: { screen_code } })
// Line 205: 화면 ID로 조회
await prisma.screen_definitions.findFirst({ where: { screen_id } })
// Line 221: 화면 존재 확인
await prisma.screen_definitions.findUnique({ where: { screen_id } })
// Line 236: 화면 업데이트
await prisma.screen_definitions.update({ where, data })
// Line 268: 화면 복사 - 원본 조회
await prisma.screen_definitions.findUnique({ where, include: { screen_layouts } })
// Line 292: 화면 순서 변경 - 전체 조회
await prisma.screen_definitions.findMany({ where })
// Line 486: 화면 템플릿 적용 - 존재 확인
await prisma.screen_definitions.findUnique({ where })
// Line 557: 화면 복사 - 존재 확인
await prisma.screen_definitions.findUnique({ where })
// Line 578: 화면 복사 - 중복 확인
await prisma.screen_definitions.findFirst({ where })
// Line 651: 화면 삭제 - 존재 확인
await prisma.screen_definitions.findUnique({ where })
// Line 672: 화면 삭제 (물리 삭제)
await prisma.screen_definitions.delete({ where })
// Line 700: 삭제된 화면 조회
await prisma.screen_definitions.findMany({ where: { is_active: "D" } })
// Line 706: 삭제된 화면 개수
await prisma.screen_definitions.count({ where })
// Line 763: 일괄 삭제 - 화면 조회
await prisma.screen_definitions.findMany({ where })
// Line 1083: 레이아웃 저장 - 화면 확인
await prisma.screen_definitions.findUnique({ where })
// Line 1181: 레이아웃 조회 - 화면 확인
await prisma.screen_definitions.findUnique({ where })
// Line 1655: 위젯 데이터 저장 - 화면 존재 확인
await prisma.screen_definitions.findMany({ where })
```
### 2. 레이아웃 관리 (Screen Layouts) - 4개
```typescript
// Line 1096: 레이아웃 삭제
await prisma.screen_layouts.deleteMany({ where: { screen_id } });
// Line 1107: 레이아웃 생성 (단일)
await prisma.screen_layouts.create({ data });
// Line 1152: 레이아웃 생성 (다중)
await prisma.screen_layouts.create({ data });
// Line 1193: 레이아웃 조회
await prisma.screen_layouts.findMany({ where });
```
### 3. 템플릿 관리 (Screen Templates) - 2개
```typescript
// Line 1303: 템플릿 목록 조회
await prisma.screen_templates.findMany({ where });
// Line 1317: 템플릿 생성
await prisma.screen_templates.create({ data });
```
### 4. 메뉴 할당 (Screen Menu Assignments) - 5개
```typescript
// Line 446: 메뉴 할당 조회
await prisma.screen_menu_assignments.findMany({ where });
// Line 1346: 메뉴 할당 중복 확인
await prisma.screen_menu_assignments.findFirst({ where });
// Line 1358: 메뉴 할당 생성
await prisma.screen_menu_assignments.create({ data });
// Line 1376: 화면별 메뉴 할당 조회
await prisma.screen_menu_assignments.findMany({ where });
// Line 1401: 메뉴 할당 삭제
await prisma.screen_menu_assignments.deleteMany({ where });
```
### 5. 테이블 레이블 (Table Labels) - 3개
```typescript
// Line 117: 테이블 레이블 조회 (페이징)
await prisma.table_labels.findMany({ where, skip, take });
// Line 713: 테이블 레이블 조회 (전체)
await prisma.table_labels.findMany({ where });
```
### 6. 컬럼 레이블 (Column Labels) - 2개
```typescript
// Line 948: 웹타입 정보 조회
await prisma.column_labels.findMany({ where, select });
// Line 1456: 컬럼 레이블 UPSERT
await prisma.column_labels.upsert({ where, create, update });
```
### 7. Raw Query 사용 (이미 있음) - 6개
```typescript
// Line 627: 화면 순서 변경 (일괄 업데이트)
await prisma.$executeRaw`UPDATE screen_definitions SET display_order = ...`;
// Line 833: 테이블 목록 조회
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
// Line 876: 테이블 존재 확인
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
// Line 922: 테이블 컬럼 정보 조회
await prisma.$queryRaw<Array<ColumnInfo>>`SELECT column_name, data_type ...`;
// Line 1418: 컬럼 정보 조회 (상세)
await prisma.$queryRaw`SELECT column_name, data_type ...`;
```
### 8. 트랜잭션 사용 - 3개
```typescript
// Line 521: 화면 템플릿 적용 트랜잭션
await prisma.$transaction(async (tx) => { ... })
// Line 593: 화면 복사 트랜잭션
await prisma.$transaction(async (tx) => { ... })
// Line 788: 일괄 삭제 트랜잭션
await prisma.$transaction(async (tx) => { ... })
// Line 1697: 위젯 데이터 저장 트랜잭션
await prisma.$transaction(async (tx) => { ... })
```
---
## 🛠️ 전환 전략
### 전략 1: 단계적 전환
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
2. **2단계**: 복잡한 조회 전환 (include, join)
3. **3단계**: 트랜잭션 전환
4. **4단계**: Raw Query 개선
### 전략 2: 함수별 전환 우선순위
#### 🔴 최우선 (기본 CRUD)
- `createScreen()` - Line 70
- `getScreensByCompany()` - Line 99-105
- `getScreenByCode()` - Line 178
- `getScreenById()` - Line 205
- `updateScreen()` - Line 236
- `deleteScreen()` - Line 672
#### 🟡 2순위 (레이아웃)
- `saveLayout()` - Line 1096-1152
- `getLayout()` - Line 1193
- `deleteLayout()` - Line 1096
#### 🟢 3순위 (템플릿 & 메뉴)
- `getTemplates()` - Line 1303
- `createTemplate()` - Line 1317
- `assignToMenu()` - Line 1358
- `getMenuAssignments()` - Line 1376
- `removeMenuAssignment()` - Line 1401
#### 🔵 4순위 (복잡한 기능)
- `copyScreen()` - Line 593 (트랜잭션)
- `applyTemplate()` - Line 521 (트랜잭션)
- `bulkDelete()` - Line 788 (트랜잭션)
- `reorderScreens()` - Line 627 (Raw Query)
---
## 📝 전환 예시
### 예시 1: createScreen() 전환
**기존 Prisma 코드:**
```typescript
// Line 53: 중복 확인
const existingScreen = await prisma.screen_definitions.findFirst({
where: {
screen_code: screenData.screenCode,
is_active: { not: "D" },
},
});
// Line 70: 생성
const screen = await prisma.screen_definitions.create({
data: {
screen_name: screenData.screenName,
screen_code: screenData.screenCode,
table_name: screenData.tableName,
company_code: screenData.companyCode,
description: screenData.description,
created_by: screenData.createdBy,
},
});
```
**새로운 Raw Query 코드:**
```typescript
import { query } from "../database/db";
// 중복 확인
const existingResult = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND is_active != 'D'
LIMIT 1`,
[screenData.screenCode]
);
if (existingResult.length > 0) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 생성
const [screen] = await query<ScreenDefinition>(
`INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, description, created_by
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
screenData.screenName,
screenData.screenCode,
screenData.tableName,
screenData.companyCode,
screenData.description,
screenData.createdBy,
]
);
```
### 예시 2: getScreensByCompany() 전환 (페이징)
**기존 Prisma 코드:**
```typescript
const [screens, total] = await Promise.all([
prisma.screen_definitions.findMany({
where: whereClause,
skip: (page - 1) * size,
take: size,
orderBy: { created_at: "desc" },
}),
prisma.screen_definitions.count({ where: whereClause }),
]);
```
**새로운 Raw Query 코드:**
```typescript
const offset = (page - 1) * size;
const whereSQL =
companyCode !== "*"
? "WHERE company_code = $1 AND is_active != 'D'"
: "WHERE is_active != 'D'";
const params =
companyCode !== "*" ? [companyCode, size, offset] : [size, offset];
const [screens, totalResult] = await Promise.all([
query<ScreenDefinition>(
`SELECT * FROM screen_definitions
${whereSQL}
ORDER BY created_at DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
),
query<{ count: number }>(
`SELECT COUNT(*) as count FROM screen_definitions ${whereSQL}`,
companyCode !== "*" ? [companyCode] : []
),
]);
const total = totalResult[0]?.count || 0;
```
### 예시 3: 트랜잭션 전환
**기존 Prisma 코드:**
```typescript
await prisma.$transaction(async (tx) => {
const newScreen = await tx.screen_definitions.create({ data: { ... } });
await tx.screen_layouts.createMany({ data: layouts });
});
```
**새로운 Raw Query 코드:**
```typescript
import { transaction } from "../database/db";
await transaction(async (client) => {
const [newScreen] = await client.query(
`INSERT INTO screen_definitions (...) VALUES (...) RETURNING *`,
[...]
);
for (const layout of layouts) {
await client.query(
`INSERT INTO screen_layouts (...) VALUES (...)`,
[...]
);
}
});
```
---
## 🧪 테스트 계획
### 단위 테스트
```typescript
describe("ScreenManagementService Raw Query 전환 테스트", () => {
describe("createScreen", () => {
test("화면 생성 성공", async () => { ... });
test("중복 화면 코드 에러", async () => { ... });
});
describe("getScreensByCompany", () => {
test("페이징 조회 성공", async () => { ... });
test("회사별 필터링", async () => { ... });
});
describe("copyScreen", () => {
test("화면 복사 성공 (트랜잭션)", async () => { ... });
test("레이아웃 함께 복사", async () => { ... });
});
});
```
### 통합 테스트
```typescript
describe("화면 관리 통합 테스트", () => {
test("화면 생성 → 조회 → 수정 → 삭제", async () => { ... });
test("화면 복사 → 레이아웃 확인", async () => { ... });
test("메뉴 할당 → 조회 → 해제", async () => { ... });
});
```
---
## 📋 체크리스트
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
- [x] `createScreen()` - 화면 생성
- [x] `getScreensByCompany()` - 화면 목록 (페이징)
- [x] `getScreenByCode()` - 화면 코드로 조회
- [x] `getScreenById()` - 화면 ID로 조회
- [x] `updateScreen()` - 화면 업데이트
- [x] `deleteScreen()` - 화면 삭제
- [x] `getScreens()` - 전체 화면 목록 조회
- [x] `getScreen()` - 회사 코드 필터링 포함 조회
### 2단계: 레이아웃 관리 (2개 함수) ✅ **완료**
- [x] `saveLayout()` - 레이아웃 저장 (메타데이터 + 컴포넌트)
- [x] `getLayout()` - 레이아웃 조회
- [x] 레이아웃 삭제 로직 (saveLayout 내부에 포함)
### 3단계: 템플릿 & 메뉴 (5개 함수) ✅ **완료**
- [x] `getTemplatesByCompany()` - 템플릿 목록
- [x] `createTemplate()` - 템플릿 생성
- [x] `assignScreenToMenu()` - 메뉴 할당
- [x] `getScreensByMenu()` - 메뉴별 화면 조회
- [x] `unassignScreenFromMenu()` - 메뉴 할당 해제
- [ ] 테이블 레이블 조회 (getScreensByCompany 내부에 포함됨)
### 4단계: 복잡한 기능 (4개 함수) ✅ **완료**
- [x] `copyScreen()` - 화면 복사 (트랜잭션)
- [x] `generateScreenCode()` - 화면 코드 자동 생성
- [x] `checkScreenDependencies()` - 화면 의존성 체크 (메뉴 할당 포함)
- [x] 모든 유틸리티 메서드 Raw Query 전환
### 5단계: 테스트 & 검증 ✅ **완료**
- [x] 단위 테스트 작성 (18개 테스트 통과)
- createScreen, updateScreen, deleteScreen
- getScreensByCompany, getScreenById
- saveLayout, getLayout
- getTemplatesByCompany, assignScreenToMenu
- copyScreen, generateScreenCode
- getTableColumns
- [x] 통합 테스트 작성 (6개 시나리오)
- 화면 생명주기 테스트 (생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제)
- 화면 복사 및 레이아웃 테스트
- 테이블 정보 조회 테스트
- 일괄 작업 테스트
- 화면 코드 자동 생성 테스트
- [x] Prisma import 완전 제거 확인
- [ ] 성능 테스트 (추후 실행 예정)
---
## 🎯 완료 기준
- ✅ **46개 Prisma 호출 모두 Raw Query로 전환 완료**
- ✅ **모든 TypeScript 컴파일 오류 해결**
- ✅ **트랜잭션 정상 동작 확인**
- ✅ **에러 처리 및 롤백 정상 동작**
- ✅ **모든 단위 테스트 통과 (18개)**
- ✅ **모든 통합 테스트 작성 완료 (6개 시나리오)**
- ✅ **Prisma import 완전 제거**
- [ ] 성능 저하 없음 (기존 대비 ±10% 이내) - 추후 측정 예정
## 📊 테스트 결과
### 단위 테스트 (18개)
```
✅ createScreen - 화면 생성 (2개 테스트)
✅ getScreensByCompany - 화면 목록 페이징 (2개 테스트)
✅ updateScreen - 화면 업데이트 (2개 테스트)
✅ deleteScreen - 화면 삭제 (2개 테스트)
✅ saveLayout - 레이아웃 저장 (2개 테스트)
- 기본 저장, 소수점 좌표 반올림 처리
✅ getLayout - 레이아웃 조회 (1개 테스트)
✅ getTemplatesByCompany - 템플릿 목록 (1개 테스트)
✅ assignScreenToMenu - 메뉴 할당 (2개 테스트)
✅ copyScreen - 화면 복사 (1개 테스트)
✅ generateScreenCode - 화면 코드 자동 생성 (2개 테스트)
✅ getTableColumns - 테이블 컬럼 정보 (1개 테스트)
Test Suites: 1 passed
Tests: 18 passed
Time: 1.922s
```
### 통합 테스트 (6개 시나리오)
```
✅ 화면 생명주기 테스트
- 생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제
✅ 화면 복사 및 레이아웃 테스트
- 화면 복사 → 레이아웃 저장 → 레이아웃 확인 → 레이아웃 수정
✅ 테이블 정보 조회 테스트
- 테이블 목록 조회 → 특정 테이블 정보 조회
✅ 일괄 작업 테스트
- 여러 화면 생성 → 일괄 삭제
✅ 화면 코드 자동 생성 테스트
- 순차적 화면 코드 생성 검증
✅ 메뉴 할당 테스트 (skip - 실제 메뉴 데이터 필요)
```
---
## 🐛 버그 수정 및 개선사항
### 실제 운영 환경에서 발견된 이슈
#### 1. 소수점 좌표 저장 오류 (해결 완료)
**문제**:
```
invalid input syntax for type integer: "1602.666666666667"
```
- `position_x`, `position_y`, `width`, `height` 컬럼이 `integer` 타입
- 격자 계산 시 소수점 값이 발생하여 저장 실패
**해결**:
```typescript
Math.round(component.position.x), // 정수로 반올림
Math.round(component.position.y),
Math.round(component.size.width),
Math.round(component.size.height),
```
**테스트 추가**:
- 소수점 좌표 저장 테스트 케이스 추가
- 반올림 처리 검증
**영향 범위**:
- `saveLayout()` 함수
- `copyScreen()` 함수 (레이아웃 복사 시)
---
**작성일**: 2025-09-30
**완료일**: 2025-09-30
**예상 소요 시간**: 2-3일 → **실제 소요 시간**: 1일
**담당자**: 백엔드 개발팀
**우선순위**: 🔴 최우선 (Phase 2.1)
**상태**: ✅ **완료**

View File

@ -0,0 +1,369 @@
# 🎨 Phase 3.7: LayoutService Raw Query 전환 계획
## 📋 개요
LayoutService는 **10개의 Prisma 호출**이 있으며, 레이아웃 표준 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | --------------------------------------------- |
| 파일 위치 | `backend-node/src/services/layoutService.ts` |
| 파일 크기 | 425+ 라인 |
| Prisma 호출 | 10개 |
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
| 복잡도 | 중간 (JSON 필드, 검색, 통계) |
| 우선순위 | 🟡 중간 (Phase 3.7) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **10개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ JSON 필드 처리 (layout_config, sections)
- ⏳ 복잡한 검색 조건 처리
- ⏳ GROUP BY 통계 쿼리 전환
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (10개)
#### 1. **getLayouts()** - 레이아웃 목록 조회
```typescript
// Line 92, 102
const total = await prisma.layout_standards.count({ where });
const layouts = await prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: { updated_date: "desc" },
});
```
#### 2. **getLayoutByCode()** - 레이아웃 단건 조회
```typescript
// Line 152
const layout = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
```
#### 3. **createLayout()** - 레이아웃 생성
```typescript
// Line 199
const layout = await prisma.layout_standards.create({
data: {
layout_code,
layout_name,
layout_type,
category,
layout_config: safeJSONStringify(layout_config),
sections: safeJSONStringify(sections),
// ... 기타 필드
},
});
```
#### 4. **updateLayout()** - 레이아웃 수정
```typescript
// Line 230, 267
const existing = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
const updated = await prisma.layout_standards.update({
where: { id: existing.id },
data: { ... },
});
```
#### 5. **deleteLayout()** - 레이아웃 삭제
```typescript
// Line 283, 295
const existing = await prisma.layout_standards.findFirst({
where: { layout_code: code, company_code: companyCode },
});
await prisma.layout_standards.update({
where: { id: existing.id },
data: { is_active: "N", updated_by, updated_date: new Date() },
});
```
#### 6. **getLayoutStatistics()** - 레이아웃 통계
```typescript
// Line 345
const counts = await prisma.layout_standards.groupBy({
by: ["category", "layout_type"],
where: { company_code: companyCode, is_active: "Y" },
_count: { id: true },
});
```
#### 7. **getLayoutCategories()** - 카테고리 목록
```typescript
// Line 373
const existingCodes = await prisma.layout_standards.findMany({
where: { company_code: companyCode },
select: { category: true },
distinct: ["category"],
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (5개 함수)
**함수 목록**:
- `getLayouts()` - 목록 조회 (count + findMany)
- `getLayoutByCode()` - 단건 조회 (findFirst)
- `createLayout()` - 생성 (create)
- `updateLayout()` - 수정 (findFirst + update)
- `deleteLayout()` - 삭제 (findFirst + update - soft delete)
### 2단계: 통계 및 집계 전환 (2개 함수)
**함수 목록**:
- `getLayoutStatistics()` - 통계 (groupBy)
- `getLayoutCategories()` - 카테고리 목록 (findMany + distinct)
---
## 💻 전환 예시
### 예시 1: 레이아웃 목록 조회 (동적 WHERE + 페이지네이션)
```typescript
// 기존 Prisma
const where: any = { company_code: companyCode };
if (category) where.category = category;
if (layoutType) where.layout_type = layoutType;
if (searchTerm) {
where.OR = [
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
{ layout_code: { contains: searchTerm, mode: "insensitive" } },
];
}
const total = await prisma.layout_standards.count({ where });
const layouts = await prisma.layout_standards.findMany({
where,
skip,
take: size,
orderBy: { updated_date: "desc" },
});
// 전환 후
import { query, queryOne } from "../database/db";
const whereConditions: string[] = ["company_code = $1"];
const values: any[] = [companyCode];
let paramIndex = 2;
if (category) {
whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
}
if (layoutType) {
whereConditions.push(`layout_type = $${paramIndex++}`);
values.push(layoutType);
}
if (searchTerm) {
whereConditions.push(
`(layout_name ILIKE $${paramIndex} OR layout_code ILIKE $${paramIndex})`
);
values.push(`%${searchTerm}%`);
paramIndex++;
}
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
// 총 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
// 데이터 조회
const layouts = await query<any>(
`SELECT * FROM layout_standards
${whereClause}
ORDER BY updated_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...values, size, skip]
);
```
### 예시 2: JSON 필드 처리 (레이아웃 생성)
```typescript
// 기존 Prisma
const layout = await prisma.layout_standards.create({
data: {
layout_code,
layout_name,
layout_config: safeJSONStringify(layout_config), // JSON 필드
sections: safeJSONStringify(sections), // JSON 필드
company_code: companyCode,
created_by: createdBy,
},
});
// 전환 후
const layout = await queryOne<any>(
`INSERT INTO layout_standards
(layout_code, layout_name, layout_type, category, layout_config, sections,
company_code, is_active, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
layout_code,
layout_name,
layout_type,
category,
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
safeJSONStringify(sections),
companyCode,
"Y",
createdBy,
updatedBy,
]
);
```
### 예시 3: GROUP BY 통계 쿼리
```typescript
// 기존 Prisma
const counts = await prisma.layout_standards.groupBy({
by: ["category", "layout_type"],
where: { company_code: companyCode, is_active: "Y" },
_count: { id: true },
});
// 전환 후
const counts = await query<{
category: string;
layout_type: string;
count: string;
}>(
`SELECT category, layout_type, COUNT(*) as count
FROM layout_standards
WHERE company_code = $1 AND is_active = $2
GROUP BY category, layout_type`,
[companyCode, "Y"]
);
// 결과 포맷팅
const formattedCounts = counts.map((row) => ({
category: row.category,
layout_type: row.layout_type,
_count: { id: parseInt(row.count) },
}));
```
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
```typescript
// 기존 Prisma
const existingCodes = await prisma.layout_standards.findMany({
where: { company_code: companyCode },
select: { category: true },
distinct: ["category"],
});
// 전환 후
const existingCodes = await query<{ category: string }>(
`SELECT DISTINCT category
FROM layout_standards
WHERE company_code = $1
ORDER BY category`,
[companyCode]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
- [ ] **JSON 필드 처리 (layout_config, sections)**
- [ ] **GROUP BY 집계 쿼리 전환**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. JSON 필드 처리
- `layout_config`, `sections` 필드는 JSON 타입
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
### 2. 동적 검색 조건
- category, layoutType, searchTerm에 따른 동적 WHERE 절
- OR 조건 처리 (layout_name OR layout_code)
### 3. Soft Delete
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
- UPDATE 쿼리 사용
### 4. 통계 쿼리
- `groupBy``GROUP BY` + `COUNT(*)` 전환
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getLayouts() - count + findMany → query + queryOne
- [ ] getLayoutByCode() - findFirst → queryOne
- [ ] createLayout() - create → queryOne (INSERT)
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### JSON 필드 헬퍼 함수
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
### Soft Delete 패턴
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
### 통계 쿼리 결과 포맷
Prisma의 `groupBy``_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.7)
**상태**: ⏳ **대기 중**
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함

View File

@ -0,0 +1,484 @@
# 🗂️ Phase 3.8: DbTypeCategoryService Raw Query 전환 계획
## 📋 개요
DbTypeCategoryService는 **10개의 Prisma 호출**이 있으며, 데이터베이스 타입 카테고리 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ------------------------------------------------------ |
| 파일 위치 | `backend-node/src/services/dbTypeCategoryService.ts` |
| 파일 크기 | 320+ 라인 |
| Prisma 호출 | 10개 |
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
| 복잡도 | 중간 (CRUD, 통계, UPSERT) |
| 우선순위 | 🟡 중간 (Phase 3.8) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **10개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ ApiResponse 래퍼 패턴 유지
- ⏳ GROUP BY 통계 쿼리 전환
- ⏳ UPSERT 로직 전환 (ON CONFLICT)
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (10개)
#### 1. **getAllCategories()** - 카테고리 목록 조회
```typescript
// Line 45
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true },
orderBy: [
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
});
```
#### 2. **getCategoryByTypeCode()** - 카테고리 단건 조회
```typescript
// Line 73
const category = await prisma.db_type_categories.findUnique({
where: { type_code: typeCode }
});
```
#### 3. **createCategory()** - 카테고리 생성
```typescript
// Line 105, 116
const existing = await prisma.db_type_categories.findUnique({
where: { type_code: data.type_code }
});
const category = await prisma.db_type_categories.create({
data: {
type_code: data.type_code,
display_name: data.display_name,
icon: data.icon,
color: data.color,
sort_order: data.sort_order ?? 0,
is_active: true,
}
});
```
#### 4. **updateCategory()** - 카테고리 수정
```typescript
// Line 146
const category = await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: updateData
});
```
#### 5. **deleteCategory()** - 카테고리 삭제 (연결 확인)
```typescript
// Line 179, 193
const connectionsCount = await prisma.external_db_connections.count({
where: { db_type: typeCode }
});
await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: { is_active: false }
});
```
#### 6. **getCategoryStatistics()** - 카테고리별 통계
```typescript
// Line 220, 229
const stats = await prisma.external_db_connections.groupBy({
by: ['db_type'],
_count: { id: true }
});
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }
});
```
#### 7. **syncPredefinedCategories()** - 사전 정의 카테고리 동기화
```typescript
// Line 300
await prisma.db_type_categories.upsert({
where: { type_code: category.type_code },
update: {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
},
create: {
type_code: category.type_code,
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
is_active: true,
},
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (5개 함수)
**함수 목록**:
- `getAllCategories()` - 목록 조회 (findMany)
- `getCategoryByTypeCode()` - 단건 조회 (findUnique)
- `createCategory()` - 생성 (findUnique + create)
- `updateCategory()` - 수정 (update)
- `deleteCategory()` - 삭제 (count + update - soft delete)
### 2단계: 통계 및 UPSERT 전환 (2개 함수)
**함수 목록**:
- `getCategoryStatistics()` - 통계 (groupBy + findMany)
- `syncPredefinedCategories()` - 동기화 (upsert)
---
## 💻 전환 예시
### 예시 1: 카테고리 목록 조회 (정렬)
```typescript
// 기존 Prisma
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true },
orderBy: [
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
});
// 전환 후
import { query } from "../database/db";
const categories = await query<DbTypeCategory>(
`SELECT * FROM db_type_categories
WHERE is_active = $1
ORDER BY sort_order ASC, display_name ASC`,
[true]
);
```
### 예시 2: 카테고리 생성 (중복 확인)
```typescript
// 기존 Prisma
const existing = await prisma.db_type_categories.findUnique({
where: { type_code: data.type_code }
});
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await prisma.db_type_categories.create({
data: {
type_code: data.type_code,
display_name: data.display_name,
icon: data.icon,
color: data.color,
sort_order: data.sort_order ?? 0,
is_active: true,
}
});
// 전환 후
import { query, queryOne } from "../database/db";
const existing = await queryOne<DbTypeCategory>(
`SELECT * FROM db_type_categories WHERE type_code = $1`,
[data.type_code]
);
if (existing) {
return {
success: false,
message: "이미 존재하는 타입 코드입니다."
};
}
const category = await queryOne<DbTypeCategory>(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *`,
[
data.type_code,
data.display_name,
data.icon || null,
data.color || null,
data.sort_order ?? 0,
true,
]
);
```
### 예시 3: 동적 UPDATE (변경된 필드만)
```typescript
// 기존 Prisma
const updateData: any = {};
if (data.display_name !== undefined) updateData.display_name = data.display_name;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.color !== undefined) updateData.color = data.color;
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
if (data.is_active !== undefined) updateData.is_active = data.is_active;
const category = await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: updateData
});
// 전환 후
const updateFields: string[] = ["updated_at = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.display_name !== undefined) {
updateFields.push(`display_name = $${paramIndex++}`);
values.push(data.display_name);
}
if (data.icon !== undefined) {
updateFields.push(`icon = $${paramIndex++}`);
values.push(data.icon);
}
if (data.color !== undefined) {
updateFields.push(`color = $${paramIndex++}`);
values.push(data.color);
}
if (data.sort_order !== undefined) {
updateFields.push(`sort_order = $${paramIndex++}`);
values.push(data.sort_order);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
const category = await queryOne<DbTypeCategory>(
`UPDATE db_type_categories
SET ${updateFields.join(", ")}
WHERE type_code = $${paramIndex}
RETURNING *`,
[...values, typeCode]
);
```
### 예시 4: 삭제 전 연결 확인
```typescript
// 기존 Prisma
const connectionsCount = await prisma.external_db_connections.count({
where: { db_type: typeCode }
});
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await prisma.db_type_categories.update({
where: { type_code: typeCode },
data: { is_active: false }
});
// 전환 후
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
[typeCode]
);
const connectionsCount = parseInt(countResult?.count || "0");
if (connectionsCount > 0) {
return {
success: false,
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
};
}
await query(
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
[false, typeCode]
);
```
### 예시 5: GROUP BY 통계 + JOIN
```typescript
// 기존 Prisma
const stats = await prisma.external_db_connections.groupBy({
by: ['db_type'],
_count: { id: true }
});
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }
});
// 전환 후
const stats = await query<{
type_code: string;
display_name: string;
connection_count: string;
}>(
`SELECT
c.type_code,
c.display_name,
COUNT(e.id) as connection_count
FROM db_type_categories c
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
WHERE c.is_active = $1
GROUP BY c.type_code, c.display_name
ORDER BY c.sort_order ASC`,
[true]
);
// 결과 포맷팅
const result = stats.map(row => ({
type_code: row.type_code,
display_name: row.display_name,
connection_count: parseInt(row.connection_count),
}));
```
### 예시 6: UPSERT (ON CONFLICT)
```typescript
// 기존 Prisma
await prisma.db_type_categories.upsert({
where: { type_code: category.type_code },
update: {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
},
create: {
type_code: category.type_code,
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order,
is_active: true,
},
});
// 전환 후
await query(
`INSERT INTO db_type_categories
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (type_code)
DO UPDATE SET
display_name = EXCLUDED.display_name,
icon = EXCLUDED.icon,
color = EXCLUDED.color,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()`,
[
category.type_code,
category.display_name,
category.icon || null,
category.color || null,
category.sort_order || 0,
true,
]
);
```
---
## ✅ 완료 기준
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
- [ ] **ON CONFLICT를 사용한 UPSERT**
- [ ] **ApiResponse 래퍼 패턴 유지**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (10개)**
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. ApiResponse 래퍼 패턴
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
### 2. Soft Delete 패턴
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
### 3. 연결 확인
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
### 4. UPSERT 로직
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
### 5. 통계 쿼리 최적화
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getAllCategories() - findMany → query
- [ ] getCategoryByTypeCode() - findUnique → queryOne
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
- [ ] deleteCategory() - count + update → queryOne + query
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
- [ ] ApiResponse 래퍼 유지
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (10개)
- [ ] 통합 테스트 작성 (3개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### ApiResponse 패턴
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
### 사전 정의 카테고리
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
### 외래 키 확인
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 1시간
**담당자**: 백엔드 개발팀
**우선순위**: 🟡 중간 (Phase 3.8)
**상태**: ⏳ **대기 중**
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함

View File

@ -0,0 +1,391 @@
# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획
## 📋 개요
TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다.
### 📊 기본 정보
| 항목 | 내용 |
| --------------- | ----------------------------------------------------------- |
| 파일 위치 | `backend-node/src/services/templateStandardService.ts` |
| 파일 크기 | 395 라인 |
| Prisma 호출 | 6개 |
| **현재 진행률** | **0/6 (0%)** 🔄 **진행 예정** |
| 복잡도 | 낮음 (기본 CRUD + DISTINCT) |
| 우선순위 | 🟢 낮음 (Phase 3.9) |
| **상태** | ⏳ **대기 중** |
### 🎯 전환 목표
- ⏳ **6개 모든 Prisma 호출을 `db.ts``query()`, `queryOne()` 함수로 교체**
- ⏳ 템플릿 CRUD 기능 정상 동작
- ⏳ DISTINCT 쿼리 전환
- ⏳ 모든 단위 테스트 통과
- ⏳ **Prisma import 완전 제거**
---
## 🔍 Prisma 사용 현황 분석
### 주요 Prisma 호출 (6개)
#### 1. **getTemplateByCode()** - 템플릿 단건 조회
```typescript
// Line 76
return await prisma.template_standards.findUnique({
where: {
template_code: templateCode,
company_code: companyCode,
},
});
```
#### 2. **createTemplate()** - 템플릿 생성
```typescript
// Line 86
const existing = await prisma.template_standards.findUnique({
where: {
template_code: data.template_code,
company_code: data.company_code,
},
});
// Line 96
return await prisma.template_standards.create({
data: {
...data,
created_date: new Date(),
updated_date: new Date(),
},
});
```
#### 3. **updateTemplate()** - 템플릿 수정
```typescript
// Line 164
return await prisma.template_standards.update({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
data: {
...data,
updated_date: new Date(),
},
});
```
#### 4. **deleteTemplate()** - 템플릿 삭제
```typescript
// Line 181
await prisma.template_standards.delete({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
});
```
#### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT)
```typescript
// Line 262
const categories = await prisma.template_standards.findMany({
where: {
company_code: companyCode,
},
select: {
category: true,
},
distinct: ["category"],
});
```
---
## 📝 전환 계획
### 1단계: 기본 CRUD 전환 (4개 함수)
**함수 목록**:
- `getTemplateByCode()` - 단건 조회 (findUnique)
- `createTemplate()` - 생성 (findUnique + create)
- `updateTemplate()` - 수정 (update)
- `deleteTemplate()` - 삭제 (delete)
### 2단계: 추가 기능 전환 (1개 함수)
**함수 목록**:
- `getTemplateCategories()` - 카테고리 목록 (findMany + distinct)
---
## 💻 전환 예시
### 예시 1: 복합 키 조회
```typescript
// 기존 Prisma
return await prisma.template_standards.findUnique({
where: {
template_code: templateCode,
company_code: companyCode,
},
});
// 전환 후
import { queryOne } from "../database/db";
return await queryOne<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 2: 중복 확인 후 생성
```typescript
// 기존 Prisma
const existing = await prisma.template_standards.findUnique({
where: {
template_code: data.template_code,
company_code: data.company_code,
},
});
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await prisma.template_standards.create({
data: {
...data,
created_date: new Date(),
updated_date: new Date(),
},
});
// 전환 후
const existing = await queryOne<any>(
`SELECT * FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[data.template_code, data.company_code]
);
if (existing) {
throw new Error("이미 존재하는 템플릿 코드입니다.");
}
return await queryOne<any>(
`INSERT INTO template_standards
(template_code, template_name, category, template_type, layout_config,
description, is_active, company_code, created_by, updated_by,
created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
data.template_code,
data.template_name,
data.category,
data.template_type,
JSON.stringify(data.layout_config),
data.description,
data.is_active,
data.company_code,
data.created_by,
data.updated_by,
]
);
```
### 예시 3: 복합 키 UPDATE
```typescript
// 기존 Prisma
return await prisma.template_standards.update({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
data: {
...data,
updated_date: new Date(),
},
});
// 전환 후
// 동적 UPDATE 쿼리 생성
const updateFields: string[] = ["updated_date = NOW()"];
const values: any[] = [];
let paramIndex = 1;
if (data.template_name !== undefined) {
updateFields.push(`template_name = $${paramIndex++}`);
values.push(data.template_name);
}
if (data.category !== undefined) {
updateFields.push(`category = $${paramIndex++}`);
values.push(data.category);
}
if (data.template_type !== undefined) {
updateFields.push(`template_type = $${paramIndex++}`);
values.push(data.template_type);
}
if (data.layout_config !== undefined) {
updateFields.push(`layout_config = $${paramIndex++}`);
values.push(JSON.stringify(data.layout_config));
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(data.updated_by);
}
return await queryOne<any>(
`UPDATE template_standards
SET ${updateFields.join(", ")}
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
RETURNING *`,
[...values, templateCode, companyCode]
);
```
### 예시 4: 복합 키 DELETE
```typescript
// 기존 Prisma
await prisma.template_standards.delete({
where: {
template_code_company_code: {
template_code: templateCode,
company_code: companyCode,
},
},
});
// 전환 후
import { query } from "../database/db";
await query(
`DELETE FROM template_standards
WHERE template_code = $1 AND company_code = $2`,
[templateCode, companyCode]
);
```
### 예시 5: DISTINCT 쿼리
```typescript
// 기존 Prisma
const categories = await prisma.template_standards.findMany({
where: {
company_code: companyCode,
},
select: {
category: true,
},
distinct: ["category"],
});
return categories
.map((c) => c.category)
.filter((c): c is string => c !== null && c !== undefined)
.sort();
// 전환 후
const categories = await query<{ category: string }>(
`SELECT DISTINCT category
FROM template_standards
WHERE company_code = $1 AND category IS NOT NULL
ORDER BY category ASC`,
[companyCode]
);
return categories.map((c) => c.category);
```
---
## ✅ 완료 기준
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
- [ ] **복합 기본 키 처리 (template_code + company_code)**
- [ ] **동적 UPDATE 쿼리 생성**
- [ ] **DISTINCT 쿼리 전환**
- [ ] **JSON 필드 처리 (layout_config)**
- [ ] **모든 TypeScript 컴파일 오류 해결**
- [ ] **`import prisma` 완전 제거**
- [ ] **모든 단위 테스트 통과 (6개)**
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
---
## 🔧 주요 기술적 과제
### 1. 복합 기본 키
`template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다.
- WHERE 절에서 두 컬럼 모두 지정 필요
- Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환
### 2. JSON 필드
`layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다.
### 3. DISTINCT + NULL 제외
카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다.
---
## 📋 체크리스트
### 코드 전환
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
- [ ] deleteTemplate() - delete → query (복합 키)
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
- [ ] JSON 필드 처리 (layout_config)
- [ ] Prisma import 완전 제거
### 테스트
- [ ] 단위 테스트 작성 (6개)
- [ ] 통합 테스트 작성 (2개)
- [ ] TypeScript 컴파일 성공
- [ ] 성능 벤치마크 테스트
---
## 💡 특이사항
### 복합 기본 키 패턴
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
### JSON 레이아웃 설정
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
### 카테고리 관리
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
---
**작성일**: 2025-10-01
**예상 소요 시간**: 45분
**담당자**: 백엔드 개발팀
**우선순위**: 🟢 낮음 (Phase 3.9)
**상태**: ⏳ **대기 중**
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,418 @@
# Phase 1: Raw Query 기반 구조 사용 가이드
## 📋 개요
Phase 1에서 구현한 Raw Query 기반 데이터베이스 아키텍처 사용 방법입니다.
---
## 🏗️ 구현된 모듈
### 1. **DatabaseManager** (`src/database/db.ts`)
PostgreSQL 연결 풀 기반 핵심 모듈
**주요 함수:**
- `query<T>(sql, params)` - 기본 쿼리 실행
- `queryOne<T>(sql, params)` - 단일 행 조회
- `transaction(callback)` - 트랜잭션 실행
- `getPool()` - 연결 풀 가져오기
- `getPoolStatus()` - 연결 풀 상태 확인
### 2. **QueryBuilder** (`src/utils/queryBuilder.ts`)
동적 쿼리 생성 유틸리티
**주요 메서드:**
- `QueryBuilder.select(tableName, options)` - SELECT 쿼리
- `QueryBuilder.insert(tableName, data, options)` - INSERT 쿼리
- `QueryBuilder.update(tableName, data, where, options)` - UPDATE 쿼리
- `QueryBuilder.delete(tableName, where, options)` - DELETE 쿼리
- `QueryBuilder.count(tableName, where)` - COUNT 쿼리
- `QueryBuilder.exists(tableName, where)` - EXISTS 쿼리
### 3. **DatabaseValidator** (`src/utils/databaseValidator.ts`)
SQL Injection 방지 및 입력 검증
**주요 메서드:**
- `validateTableName(tableName)` - 테이블명 검증
- `validateColumnName(columnName)` - 컬럼명 검증
- `validateWhereClause(where)` - WHERE 조건 검증
- `sanitizeInput(input)` - 입력 값 Sanitize
### 4. **타입 정의** (`src/types/database.ts`)
TypeScript 타입 안전성 보장
---
## 🚀 사용 예제
### 1. 기본 쿼리 실행
```typescript
import { query, queryOne } from '../database/db';
// 여러 행 조회
const users = await query<User>(
'SELECT * FROM users WHERE status = $1',
['active']
);
// 단일 행 조회
const user = await queryOne<User>(
'SELECT * FROM users WHERE user_id = $1',
['user123']
);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
```
### 2. QueryBuilder 사용
#### SELECT
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// 기본 SELECT
const { query: sql, params } = QueryBuilder.select('users', {
where: { status: 'active' },
orderBy: 'created_at DESC',
limit: 10,
});
const users = await query(sql, params);
// 복잡한 SELECT (JOIN, WHERE, ORDER BY)
const { query: sql2, params: params2 } = QueryBuilder.select('users', {
columns: ['users.user_id', 'users.username', 'departments.dept_name'],
joins: [
{
type: 'LEFT',
table: 'departments',
on: 'users.dept_id = departments.dept_id',
},
],
where: { 'users.status': 'active' },
orderBy: ['users.created_at DESC', 'users.username ASC'],
limit: 20,
offset: 0,
});
const result = await query(sql2, params2);
```
#### INSERT
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// 기본 INSERT
const { query: sql, params } = QueryBuilder.insert(
'users',
{
user_id: 'new_user',
username: 'John Doe',
email: 'john@example.com',
status: 'active',
},
{
returning: ['id', 'user_id'],
}
);
const [newUser] = await query(sql, params);
console.log('생성된 사용자 ID:', newUser.id);
// UPSERT (INSERT ... ON CONFLICT)
const { query: sql2, params: params2 } = QueryBuilder.insert(
'users',
{
user_id: 'user123',
username: 'Jane',
email: 'jane@example.com',
},
{
onConflict: {
columns: ['user_id'],
action: 'DO UPDATE',
updateSet: ['username', 'email'],
},
returning: ['*'],
}
);
const [upsertedUser] = await query(sql2, params2);
```
#### UPDATE
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
const { query: sql, params } = QueryBuilder.update(
'users',
{
username: 'Updated Name',
email: 'updated@example.com',
updated_at: new Date(),
},
{
user_id: 'user123',
},
{
returning: ['*'],
}
);
const [updatedUser] = await query(sql, params);
```
#### DELETE
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
const { query: sql, params } = QueryBuilder.delete(
'users',
{
user_id: 'user_to_delete',
},
{
returning: ['user_id', 'username'],
}
);
const [deletedUser] = await query(sql, params);
console.log('삭제된 사용자:', deletedUser.username);
```
### 3. 트랜잭션 사용
```typescript
import { transaction } from '../database/db';
// 복잡한 트랜잭션 처리
const result = await transaction(async (client) => {
// 1. 사용자 생성
const userResult = await client.query(
'INSERT INTO users (user_id, username, email) VALUES ($1, $2, $3) RETURNING id',
['new_user', 'John', 'john@example.com']
);
const userId = userResult.rows[0].id;
// 2. 역할 할당
await client.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
[userId, 'admin']
);
// 3. 로그 생성
await client.query(
'INSERT INTO audit_logs (action, user_id, details) VALUES ($1, $2, $3)',
['USER_CREATED', userId, JSON.stringify({ username: 'John' })]
);
return { success: true, userId };
});
console.log('트랜잭션 완료:', result);
```
### 4. JSON 필드 쿼리 (JSONB)
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// JSON 필드 쿼리 (config->>'type' = 'form')
const { query: sql, params } = QueryBuilder.select('screen_management', {
columns: ['*'],
where: {
company_code: 'COMPANY_001',
"config->>'type'": 'form',
},
});
const screens = await query(sql, params);
```
### 5. 동적 테이블 쿼리
```typescript
import { query } from '../database/db';
import { DatabaseValidator } from '../utils/databaseValidator';
async function queryDynamicTable(tableName: string, filters: Record<string, any>) {
// 테이블명 검증 (SQL Injection 방지)
if (!DatabaseValidator.validateTableName(tableName)) {
throw new Error('유효하지 않은 테이블명입니다.');
}
// WHERE 조건 검증
if (!DatabaseValidator.validateWhereClause(filters)) {
throw new Error('유효하지 않은 WHERE 조건입니다.');
}
const { query: sql, params } = QueryBuilder.select(tableName, {
where: filters,
});
return await query(sql, params);
}
// 사용 예
const data = await queryDynamicTable('company_data_001', {
status: 'active',
region: 'Seoul',
});
```
---
## 🔐 보안 고려사항
### 1. **항상 Parameterized Query 사용**
```typescript
// ❌ 위험: SQL Injection 취약
const userId = req.params.userId;
const sql = `SELECT * FROM users WHERE user_id = '${userId}'`;
const users = await query(sql);
// ✅ 안전: Parameterized Query
const userId = req.params.userId;
const users = await query('SELECT * FROM users WHERE user_id = $1', [userId]);
```
### 2. **식별자 검증**
```typescript
import { DatabaseValidator } from '../utils/databaseValidator';
// 테이블명/컬럼명 검증
if (!DatabaseValidator.validateTableName(tableName)) {
throw new Error('유효하지 않은 테이블명입니다.');
}
if (!DatabaseValidator.validateColumnName(columnName)) {
throw new Error('유효하지 않은 컬럼명입니다.');
}
```
### 3. **입력 값 Sanitize**
```typescript
import { DatabaseValidator } from '../utils/databaseValidator';
const sanitizedData = DatabaseValidator.sanitizeInput(userInput);
```
---
## 📊 성능 최적화 팁
### 1. **연결 풀 모니터링**
```typescript
import { getPoolStatus } from '../database/db';
const status = getPoolStatus();
console.log('연결 풀 상태:', {
total: status.totalCount,
idle: status.idleCount,
waiting: status.waitingCount,
});
```
### 2. **배치 INSERT**
```typescript
import { transaction } from '../database/db';
// 대량 데이터 삽입 시 트랜잭션 사용
await transaction(async (client) => {
for (const item of largeDataset) {
await client.query('INSERT INTO items (name, value) VALUES ($1, $2)', [
item.name,
item.value,
]);
}
});
```
### 3. **인덱스 활용 쿼리**
```typescript
// WHERE 절에 인덱스 컬럼 사용
const { query: sql, params } = QueryBuilder.select('users', {
where: {
user_id: 'user123', // 인덱스 컬럼
},
});
```
---
## 🧪 테스트 실행
```bash
# 테스트 실행
npm test -- database.test.ts
# 특정 테스트만 실행
npm test -- database.test.ts -t "QueryBuilder"
```
---
## 🚨 에러 핸들링
```typescript
import { query } from '../database/db';
try {
const users = await query('SELECT * FROM users WHERE status = $1', ['active']);
return users;
} catch (error: any) {
console.error('쿼리 실행 실패:', error.message);
// PostgreSQL 에러 코드 확인
if (error.code === '23505') {
throw new Error('중복된 값이 존재합니다.');
}
if (error.code === '23503') {
throw new Error('외래 키 제약 조건 위반입니다.');
}
throw error;
}
```
---
## 📝 다음 단계 (Phase 2)
Phase 1 기반 구조가 완성되었으므로, Phase 2에서는:
1. **screenManagementService.ts** 전환 (46개 호출)
2. **tableManagementService.ts** 전환 (35개 호출)
3. **dataflowService.ts** 전환 (31개 호출)
등 핵심 서비스를 Raw Query로 전환합니다.
---
**작성일**: 2025-09-30
**버전**: 1.0.0
**담당**: Backend Development Team

View File

@ -47,7 +47,7 @@
"@types/oracledb": "^6.9.1", "@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5", "@types/sanitize-html": "^2.9.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0", "eslint": "^8.55.0",
@ -55,7 +55,7 @@
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"prisma": "^6.16.2", "prisma": "^6.16.2",
"supertest": "^6.3.3", "supertest": "^6.3.4",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View File

@ -65,7 +65,7 @@
"@types/oracledb": "^6.9.1", "@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5", "@types/sanitize-html": "^2.9.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0", "eslint": "^8.55.0",
@ -73,7 +73,7 @@
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"prisma": "^6.16.2", "prisma": "^6.16.2",
"supertest": "^6.3.3", "supertest": "^6.3.4",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View File

@ -65,12 +65,26 @@ export class CommonCodeController {
// 프론트엔드가 기대하는 형식으로 데이터 변환 // 프론트엔드가 기대하는 형식으로 데이터 변환
const transformedData = result.data.map((code: any) => ({ const transformedData = result.data.map((code: any) => ({
// 새로운 필드명 (카멜케이스)
codeValue: code.code_value, codeValue: code.code_value,
codeName: code.code_name, codeName: code.code_name,
codeNameEng: code.code_name_eng,
description: code.description, description: code.description,
sortOrder: code.sort_order, sortOrder: code.sort_order,
isActive: code.is_active === "Y", isActive: code.is_active,
useYn: code.is_active, useYn: code.is_active,
// 기존 필드명도 유지 (하위 호환성)
code_category: code.code_category,
code_value: code.code_value,
code_name: code.code_name,
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
created_date: code.created_date,
created_by: code.created_by,
updated_date: code.updated_date,
updated_by: code.updated_by,
})); }));
return res.json({ return res.json({

View File

@ -0,0 +1,271 @@
/**
* PostgreSQL Raw Query
*
* Prisma Raw Query
* - Connection Pool
* -
* -
* -
*/
import {
Pool,
PoolClient,
QueryResult as PgQueryResult,
QueryResultRow,
} from "pg";
import config from "../config/environment";
// PostgreSQL 연결 풀
let pool: Pool | null = null;
/**
*
*/
export const initializePool = (): Pool => {
if (pool) {
return pool;
}
// DATABASE_URL 파싱 (postgresql://user:password@host:port/database)
const databaseUrl = config.databaseUrl;
// URL 파싱 로직
const dbConfig = parseDatabaseUrl(databaseUrl);
pool = new Pool({
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database,
user: dbConfig.user,
password: dbConfig.password,
// 연결 풀 설정
min: config.nodeEnv === "production" ? 5 : 2,
max: config.nodeEnv === "production" ? 20 : 10,
// 타임아웃 설정
connectionTimeoutMillis: 30000, // 30초
idleTimeoutMillis: 600000, // 10분
// 연결 유지 설정
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
// 쿼리 타임아웃
statement_timeout: 60000, // 60초 (동적 테이블 생성 등 고려)
query_timeout: 60000,
// Application Name
application_name: "WACE-PLM-Backend",
});
// 연결 풀 이벤트 핸들러
pool.on("connect", (client) => {
if (config.debug) {
console.log("✅ PostgreSQL 클라이언트 연결 생성");
}
});
pool.on("acquire", (client) => {
if (config.debug) {
console.log("🔒 PostgreSQL 클라이언트 획득");
}
});
pool.on("remove", (client) => {
if (config.debug) {
console.log("🗑️ PostgreSQL 클라이언트 제거");
}
});
pool.on("error", (err, client) => {
console.error("❌ PostgreSQL 연결 풀 에러:", err);
});
console.log(
`🚀 PostgreSQL 연결 풀 초기화 완료: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
);
return pool;
};
/**
* DATABASE_URL
*/
function parseDatabaseUrl(url: string) {
// postgresql://user:password@host:port/database
const regex = /postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/;
const match = url.match(regex);
if (!match) {
// URL 파싱 실패 시 기본값 사용
console.warn("⚠️ DATABASE_URL 파싱 실패, 기본값 사용");
return {
host: "localhost",
port: 5432,
database: "ilshin",
user: "postgres",
password: "postgres",
};
}
return {
user: decodeURIComponent(match[1]),
password: decodeURIComponent(match[2]),
host: match[3],
port: parseInt(match[4], 10),
database: match[5],
};
}
/**
*
*/
export const getPool = (): Pool => {
if (!pool) {
return initializePool();
}
return pool;
};
/**
*
*
* @param text SQL (Parameterized Query)
* @param params
* @returns
*
* @example
* const users = await query<User>('SELECT * FROM users WHERE user_id = $1', ['user123']);
*/
export async function query<T extends QueryResultRow = any>(
text: string,
params?: any[]
): Promise<T[]> {
const pool = getPool();
const client = await pool.connect();
try {
const startTime = Date.now();
const result: PgQueryResult<T> = await client.query(text, params);
const duration = Date.now() - startTime;
if (config.debug) {
console.log("🔍 쿼리 실행:", {
query: text,
params,
rowCount: result.rowCount,
duration: `${duration}ms`,
});
}
return result.rows;
} catch (error: any) {
console.error("❌ 쿼리 실행 실패:", {
query: text,
params,
error: error.message,
});
throw error;
} finally {
client.release();
}
}
/**
* ( null )
*
* @param text SQL
* @param params
* @returns null
*
* @example
* const user = await queryOne<User>('SELECT * FROM users WHERE user_id = $1', ['user123']);
*/
export async function queryOne<T extends QueryResultRow = any>(
text: string,
params?: any[]
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows.length > 0 ? rows[0] : null;
}
/**
*
*
* @param callback
* @returns
*
* @example
* const result = await transaction(async (client) => {
* await client.query('INSERT INTO users (...) VALUES (...)', []);
* await client.query('INSERT INTO user_roles (...) VALUES (...)', []);
* return { success: true };
* });
*/
export async function transaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
if (config.debug) {
console.log("🔄 트랜잭션 시작");
}
const result = await callback(client);
await client.query("COMMIT");
if (config.debug) {
console.log("✅ 트랜잭션 커밋 완료");
}
return result;
} catch (error: any) {
await client.query("ROLLBACK");
console.error("❌ 트랜잭션 롤백:", error.message);
throw error;
} finally {
client.release();
}
}
/**
* ( )
*/
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
console.log("🛑 PostgreSQL 연결 풀 종료");
}
}
/**
*
*/
export function getPoolStatus() {
const pool = getPool();
return {
totalCount: pool.totalCount,
idleCount: pool.idleCount,
waitingCount: pool.waitingCount,
};
}
// 기본 익스포트 (편의성)
export default {
query,
queryOne,
transaction,
getPool,
initializePool,
closePool,
getPoolStatus,
};

View File

@ -47,8 +47,8 @@ export const requireSuperAdmin = (
return; return;
} }
// 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자) // 슈퍼관리자 권한 확인 (회사코드가 '*' 사용자)
if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") { if (req.user.companyCode !== "*") {
logger.warn("DDL 실행 시도 - 권한 부족", { logger.warn("DDL 실행 시도 - 권한 부족", {
userId: req.user.userId, userId: req.user.userId,
companyCode: req.user.companyCode, companyCode: req.user.companyCode,
@ -62,7 +62,7 @@ export const requireSuperAdmin = (
error: { error: {
code: "SUPER_ADMIN_REQUIRED", code: "SUPER_ADMIN_REQUIRED",
details: details:
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.", "최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 사용자만 가능합니다.",
}, },
}); });
return; return;
@ -167,7 +167,7 @@ export const validateDDLPermission = (
* *
*/ */
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => { export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
return user?.companyCode === "*" && user?.userId === "plm_admin"; return user?.companyCode === "*";
}; };
/** /**

View File

@ -1,7 +1,8 @@
// 인증 서비스 // 인증 서비스
// 기존 Java LoginService를 Node.js로 포팅 // 기존 Java LoginService를 Node.js로 포팅
// ✅ Prisma → Raw Query 전환 완료 (Phase 1.5)
import prisma from "../config/database"; import { query } from "../database/db";
import { JwtUtils } from "../utils/jwtUtils"; import { JwtUtils } from "../utils/jwtUtils";
import { EncryptUtil } from "../utils/encryptUtil"; import { EncryptUtil } from "../utils/encryptUtil";
import { PersonBean, LoginResult, LoginLogData } from "../types/auth"; import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
@ -17,15 +18,13 @@ export class AuthService {
password: string password: string
): Promise<LoginResult> { ): Promise<LoginResult> {
try { try {
// 사용자 비밀번호 조회 (기존 login.getUserPassword 쿼리 포팅) // 사용자 비밀번호 조회 (Raw Query 전환)
const userInfo = await prisma.user_info.findUnique({ const result = await query<{ user_password: string }>(
where: { "SELECT user_password FROM user_info WHERE user_id = $1",
user_id: userId, [userId]
}, );
select: {
user_password: true, const userInfo = result.length > 0 ? result[0] : null;
},
});
if (userInfo && userInfo.user_password) { if (userInfo && userInfo.user_password) {
const dbPassword = userInfo.user_password; const dbPassword = userInfo.user_password;
@ -78,32 +77,26 @@ export class AuthService {
*/ */
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> { static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
try { try {
// 기존 login.insertLoginAccessLog 쿼리 포팅 // 로그인 로그 기록 (Raw Query 전환)
await prisma.$executeRaw` await query(
INSERT INTO LOGIN_ACCESS_LOG( `INSERT INTO LOGIN_ACCESS_LOG(
LOG_TIME, LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
SYSTEM_NAME, REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
USER_ID,
LOGIN_RESULT,
ERROR_MESSAGE,
REMOTE_ADDR,
RECPTN_DT,
RECPTN_RSLT_DTL,
RECPTN_RSLT,
RECPTN_RSLT_CD
) VALUES ( ) VALUES (
now(), now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
${logData.systemName}, )`,
UPPER(${logData.userId}), [
${logData.loginResult}, logData.systemName,
${logData.errorMessage || null}, logData.userId,
${logData.remoteAddr}, logData.loginResult,
${logData.recptnDt || null}, logData.errorMessage || null,
${logData.recptnRsltDtl || null}, logData.remoteAddr,
${logData.recptnRslt || null}, logData.recptnDt || null,
${logData.recptnRsltCd || null} logData.recptnRsltDtl || null,
) logData.recptnRslt || null,
`; logData.recptnRsltCd || null,
]
);
logger.info( logger.info(
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
@ -122,66 +115,61 @@ export class AuthService {
*/ */
static async getUserInfo(userId: string): Promise<PersonBean | null> { static async getUserInfo(userId: string): Promise<PersonBean | null> {
try { try {
// 기존 login.getUserInfo 쿼리 포팅 // 1. 사용자 기본 정보 조회 (Raw Query 전환)
const userInfo = await prisma.user_info.findUnique({ const userResult = await query<{
where: { sabun: string | null;
user_id: userId, user_id: string;
}, user_name: string;
select: { user_name_eng: string | null;
sabun: true, user_name_cn: string | null;
user_id: true, dept_code: string | null;
user_name: true, dept_name: string | null;
user_name_eng: true, position_code: string | null;
user_name_cn: true, position_name: string | null;
dept_code: true, email: string | null;
dept_name: true, tel: string | null;
position_code: true, cell_phone: string | null;
position_name: true, user_type: string | null;
email: true, user_type_name: string | null;
tel: true, partner_objid: string | null;
cell_phone: true, company_code: string | null;
user_type: true, locale: string | null;
user_type_name: true, photo: Buffer | null;
partner_objid: true, }>(
company_code: true, `SELECT
locale: true, sabun, user_id, user_name, user_name_eng, user_name_cn,
photo: true, dept_code, dept_name, position_code, position_name,
}, email, tel, cell_phone, user_type, user_type_name,
}); partner_objid, company_code, locale, photo
FROM user_info
WHERE user_id = $1`,
[userId]
);
const userInfo = userResult.length > 0 ? userResult[0] : null;
if (!userInfo) { if (!userInfo) {
return null; return null;
} }
// 권한 정보 조회 (Prisma ORM 사용) // 2. 권한 정보 조회 (Raw Query 전환 - JOIN으로 최적화)
const authInfo = await prisma.authority_sub_user.findMany({ const authResult = await query<{ auth_name: string }>(
where: { `SELECT am.auth_name
user_id: userId, FROM authority_sub_user asu
}, INNER JOIN authority_master am ON asu.master_objid = am.objid
include: { WHERE asu.user_id = $1`,
authority_master: { [userId]
select: { );
auth_name: true,
},
},
},
});
// 권한명들을 쉼표로 연결 // 권한명들을 쉼표로 연결
const authNames = authInfo const authNames = authResult.map((row) => row.auth_name).join(",");
.filter((auth: any) => auth.authority_master?.auth_name)
.map((auth: any) => auth.authority_master!.auth_name!)
.join(",");
// 회사 정보 조회 (Prisma ORM 사용으로 변경) // 3. 회사 정보 조회 (Raw Query 전환)
const companyInfo = await prisma.company_mng.findFirst({ // Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지
where: { const companyResult = await query<{ company_name: string }>(
company_code: userInfo.company_code || "ILSHIN", "SELECT company_name FROM company_mng WHERE company_code = $1",
}, [userInfo.company_code || "ILSHIN"]
select: { );
company_name: true,
},
});
// DB에서 조회한 원본 사용자 정보 상세 로그 // DB에서 조회한 원본 사용자 정보 상세 로그
//console.log("🔍 AuthService - DB 원본 사용자 정보:", { //console.log("🔍 AuthService - DB 원본 사용자 정보:", {

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// 수집 관리 서비스 // 수집 관리 서비스
// 작성일: 2024-12-23 // 작성일: 2024-12-23
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import { import {
DataCollectionConfig, DataCollectionConfig,
CollectionFilter, CollectionFilter,
@ -9,8 +9,6 @@ import {
CollectionHistory, CollectionHistory,
} from "../types/collectionManagement"; } from "../types/collectionManagement";
const prisma = new PrismaClient();
export class CollectionService { export class CollectionService {
/** /**
* *
@ -18,40 +16,44 @@ export class CollectionService {
static async getCollectionConfigs( static async getCollectionConfigs(
filter: CollectionFilter filter: CollectionFilter
): Promise<DataCollectionConfig[]> { ): Promise<DataCollectionConfig[]> {
const whereCondition: any = { const whereConditions: string[] = ["company_code = $1"];
company_code: filter.company_code || "*", const values: any[] = [filter.company_code || "*"];
}; let paramIndex = 2;
if (filter.config_name) { if (filter.config_name) {
whereCondition.config_name = { whereConditions.push(`config_name ILIKE $${paramIndex++}`);
contains: filter.config_name, values.push(`%${filter.config_name}%`);
mode: "insensitive",
};
} }
if (filter.source_connection_id) { if (filter.source_connection_id) {
whereCondition.source_connection_id = filter.source_connection_id; whereConditions.push(`source_connection_id = $${paramIndex++}`);
values.push(filter.source_connection_id);
} }
if (filter.collection_type) { if (filter.collection_type) {
whereCondition.collection_type = filter.collection_type; whereConditions.push(`collection_type = $${paramIndex++}`);
values.push(filter.collection_type);
} }
if (filter.is_active) { if (filter.is_active) {
whereCondition.is_active = filter.is_active === "Y"; whereConditions.push(`is_active = $${paramIndex++}`);
values.push(filter.is_active === "Y");
} }
if (filter.search) { if (filter.search) {
whereCondition.OR = [ whereConditions.push(
{ config_name: { contains: filter.search, mode: "insensitive" } }, `(config_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
{ description: { contains: filter.search, mode: "insensitive" } }, );
]; values.push(`%${filter.search}%`);
paramIndex++;
} }
const configs = await prisma.data_collection_configs.findMany({ const configs = await query<any>(
where: whereCondition, `SELECT * FROM data_collection_configs
orderBy: { created_date: "desc" }, WHERE ${whereConditions.join(" AND ")}
}); ORDER BY created_date DESC`,
values
);
return configs.map((config: any) => ({ return configs.map((config: any) => ({
...config, ...config,
@ -65,9 +67,10 @@ export class CollectionService {
static async getCollectionConfigById( static async getCollectionConfigById(
id: number id: number
): Promise<DataCollectionConfig | null> { ): Promise<DataCollectionConfig | null> {
const config = await prisma.data_collection_configs.findUnique({ const config = await queryOne<any>(
where: { id }, `SELECT * FROM data_collection_configs WHERE id = $1`,
}); [id]
);
if (!config) return null; if (!config) return null;
@ -84,15 +87,26 @@ export class CollectionService {
data: DataCollectionConfig data: DataCollectionConfig
): Promise<DataCollectionConfig> { ): Promise<DataCollectionConfig> {
const { id, collection_options, ...createData } = data; const { id, collection_options, ...createData } = data;
const config = await prisma.data_collection_configs.create({ const config = await queryOne<any>(
data: { `INSERT INTO data_collection_configs
...createData, (config_name, company_code, source_connection_id, collection_type,
is_active: data.is_active, collection_options, schedule_cron, is_active, description,
collection_options: collection_options || undefined, created_by, updated_by, created_date, updated_date)
created_date: new Date(), VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
updated_date: new Date(), RETURNING *`,
}, [
}); createData.config_name,
createData.company_code,
createData.source_connection_id,
createData.collection_type,
collection_options ? JSON.stringify(collection_options) : null,
createData.schedule_cron,
data.is_active,
createData.description,
createData.created_by,
createData.updated_by,
]
);
return { return {
...config, ...config,
@ -107,19 +121,52 @@ export class CollectionService {
id: number, id: number,
data: Partial<DataCollectionConfig> data: Partial<DataCollectionConfig>
): Promise<DataCollectionConfig> { ): Promise<DataCollectionConfig> {
const updateData: any = { const updateFields: string[] = ["updated_date = NOW()"];
...data, const values: any[] = [];
updated_date: new Date(), let paramIndex = 1;
};
if (data.config_name !== undefined) {
updateFields.push(`config_name = $${paramIndex++}`);
values.push(data.config_name);
}
if (data.source_connection_id !== undefined) {
updateFields.push(`source_connection_id = $${paramIndex++}`);
values.push(data.source_connection_id);
}
if (data.collection_type !== undefined) {
updateFields.push(`collection_type = $${paramIndex++}`);
values.push(data.collection_type);
}
if (data.collection_options !== undefined) {
updateFields.push(`collection_options = $${paramIndex++}`);
values.push(
data.collection_options ? JSON.stringify(data.collection_options) : null
);
}
if (data.schedule_cron !== undefined) {
updateFields.push(`schedule_cron = $${paramIndex++}`);
values.push(data.schedule_cron);
}
if (data.is_active !== undefined) { if (data.is_active !== undefined) {
updateData.is_active = data.is_active; updateFields.push(`is_active = $${paramIndex++}`);
values.push(data.is_active);
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(data.updated_by);
} }
const config = await prisma.data_collection_configs.update({ const config = await queryOne<any>(
where: { id }, `UPDATE data_collection_configs
data: updateData, SET ${updateFields.join(", ")}
}); WHERE id = $${paramIndex}
RETURNING *`,
[...values, id]
);
return { return {
...config, ...config,
@ -131,18 +178,17 @@ export class CollectionService {
* *
*/ */
static async deleteCollectionConfig(id: number): Promise<void> { static async deleteCollectionConfig(id: number): Promise<void> {
await prisma.data_collection_configs.delete({ await query(`DELETE FROM data_collection_configs WHERE id = $1`, [id]);
where: { id },
});
} }
/** /**
* *
*/ */
static async executeCollection(configId: number): Promise<CollectionJob> { static async executeCollection(configId: number): Promise<CollectionJob> {
const config = await prisma.data_collection_configs.findUnique({ const config = await queryOne<any>(
where: { id: configId }, `SELECT * FROM data_collection_configs WHERE id = $1`,
}); [configId]
);
if (!config) { if (!config) {
throw new Error("수집 설정을 찾을 수 없습니다."); throw new Error("수집 설정을 찾을 수 없습니다.");
@ -153,14 +199,13 @@ export class CollectionService {
} }
// 수집 작업 기록 생성 // 수집 작업 기록 생성
const job = await prisma.data_collection_jobs.create({ const job = await queryOne<any>(
data: { `INSERT INTO data_collection_jobs
config_id: configId, (config_id, job_status, started_at, created_date)
job_status: "running", VALUES ($1, $2, NOW(), NOW())
started_at: new Date(), RETURNING *`,
created_date: new Date(), [configId, "running"]
}, );
});
// 실제 수집 작업 실행 로직은 여기에 구현 // 실제 수집 작업 실행 로직은 여기에 구현
// 현재는 시뮬레이션으로 처리 // 현재는 시뮬레이션으로 처리
@ -171,24 +216,23 @@ export class CollectionService {
const recordsCollected = Math.floor(Math.random() * 1000) + 100; const recordsCollected = Math.floor(Math.random() * 1000) + 100;
await prisma.data_collection_jobs.update({ await query(
where: { id: job.id }, `UPDATE data_collection_jobs
data: { SET job_status = $1, completed_at = NOW(), records_processed = $2
job_status: "completed", WHERE id = $3`,
completed_at: new Date(), ["completed", recordsCollected, job.id]
records_processed: recordsCollected, );
},
});
} catch (error) { } catch (error) {
await prisma.data_collection_jobs.update({ await query(
where: { id: job.id }, `UPDATE data_collection_jobs
data: { SET job_status = $1, completed_at = NOW(), error_message = $2
job_status: "failed", WHERE id = $3`,
completed_at: new Date(), [
error_message: "failed",
error instanceof Error ? error.message : "알 수 없는 오류", error instanceof Error ? error.message : "알 수 없는 오류",
}, job.id,
}); ]
);
} }
}, 0); }, 0);
@ -199,24 +243,21 @@ export class CollectionService {
* *
*/ */
static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> { static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> {
const whereCondition: any = {}; let sql = `
SELECT j.*, c.config_name, c.collection_type
FROM data_collection_jobs j
LEFT JOIN data_collection_configs c ON j.config_id = c.id
`;
const values: any[] = [];
if (configId) { if (configId) {
whereCondition.config_id = configId; sql += ` WHERE j.config_id = $1`;
values.push(configId);
} }
const jobs = await prisma.data_collection_jobs.findMany({ sql += ` ORDER BY j.started_at DESC`;
where: whereCondition,
orderBy: { started_at: "desc" }, const jobs = await query<any>(sql, values);
include: {
config: {
select: {
config_name: true,
collection_type: true,
},
},
},
});
return jobs as CollectionJob[]; return jobs as CollectionJob[];
} }
@ -227,11 +268,13 @@ export class CollectionService {
static async getCollectionHistory( static async getCollectionHistory(
configId: number configId: number
): Promise<CollectionHistory[]> { ): Promise<CollectionHistory[]> {
const history = await prisma.data_collection_jobs.findMany({ const history = await query<any>(
where: { config_id: configId }, `SELECT * FROM data_collection_jobs
orderBy: { started_at: "desc" }, WHERE config_id = $1
take: 50, // 최근 50개 이력 ORDER BY started_at DESC
}); LIMIT 50`,
[configId]
);
return history.map((item: any) => ({ return history.map((item: any) => ({
id: item.id, id: item.id,

View File

@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import prisma from "../config/database";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
export interface CodeCategory { export interface CodeCategory {
@ -69,30 +68,46 @@ export class CommonCodeService {
try { try {
const { search, isActive, page = 1, size = 20 } = params; const { search, isActive, page = 1, size = 20 } = params;
let whereClause: any = {}; const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (search) { if (search) {
whereClause.OR = [ whereConditions.push(
{ category_name: { contains: search, mode: "insensitive" } }, `(category_name ILIKE $${paramIndex} OR category_code ILIKE $${paramIndex})`
{ category_code: { contains: search, mode: "insensitive" } }, );
]; values.push(`%${search}%`);
paramIndex++;
} }
if (isActive !== undefined) { if (isActive !== undefined) {
whereClause.is_active = isActive ? "Y" : "N"; whereConditions.push(`is_active = $${paramIndex++}`);
values.push(isActive ? "Y" : "N");
} }
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const offset = (page - 1) * size; const offset = (page - 1) * size;
const [categories, total] = await Promise.all([ // 카테고리 조회
prisma.code_category.findMany({ const categories = await query<CodeCategory>(
where: whereClause, `SELECT * FROM code_category
orderBy: [{ sort_order: "asc" }, { category_code: "asc" }], ${whereClause}
skip: offset, ORDER BY sort_order ASC, category_code ASC
take: size, LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
}), [...values, size, offset]
prisma.code_category.count({ where: whereClause }), );
]);
// 전체 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM code_category ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
logger.info( logger.info(
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}` `카테고리 조회 완료: ${categories.length}개, 전체: ${total}`
@ -115,32 +130,43 @@ export class CommonCodeService {
try { try {
const { search, isActive, page = 1, size = 20 } = params; const { search, isActive, page = 1, size = 20 } = params;
let whereClause: any = { const whereConditions: string[] = ["code_category = $1"];
code_category: categoryCode, const values: any[] = [categoryCode];
}; let paramIndex = 2;
if (search) { if (search) {
whereClause.OR = [ whereConditions.push(
{ code_name: { contains: search, mode: "insensitive" } }, `(code_name ILIKE $${paramIndex} OR code_value ILIKE $${paramIndex})`
{ code_value: { contains: search, mode: "insensitive" } }, );
]; values.push(`%${search}%`);
paramIndex++;
} }
if (isActive !== undefined) { if (isActive !== undefined) {
whereClause.is_active = isActive ? "Y" : "N"; whereConditions.push(`is_active = $${paramIndex++}`);
values.push(isActive ? "Y" : "N");
} }
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const offset = (page - 1) * size; const offset = (page - 1) * size;
const [codes, total] = await Promise.all([ // 코드 조회
prisma.code_info.findMany({ const codes = await query<CodeInfo>(
where: whereClause, `SELECT * FROM code_info
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }], ${whereClause}
skip: offset, ORDER BY sort_order ASC, code_value ASC
take: size, LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
}), [...values, size, offset]
prisma.code_info.count({ where: whereClause }), );
]);
// 전체 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM code_info ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
logger.info( logger.info(
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}` `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}`
@ -158,18 +184,22 @@ export class CommonCodeService {
*/ */
async createCategory(data: CreateCategoryData, createdBy: string) { async createCategory(data: CreateCategoryData, createdBy: string) {
try { try {
const category = await prisma.code_category.create({ const category = await queryOne<CodeCategory>(
data: { `INSERT INTO code_category
category_code: data.categoryCode, (category_code, category_name, category_name_eng, description, sort_order,
category_name: data.categoryName, is_active, created_by, updated_by, created_date, updated_date)
category_name_eng: data.categoryNameEng, VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, NOW(), NOW())
description: data.description, RETURNING *`,
sort_order: data.sortOrder || 0, [
is_active: "Y", data.categoryCode,
created_by: createdBy, data.categoryName,
updated_by: createdBy, data.categoryNameEng || null,
}, data.description || null,
}); data.sortOrder || 0,
createdBy,
createdBy,
]
);
logger.info(`카테고리 생성 완료: ${data.categoryCode}`); logger.info(`카테고리 생성 완료: ${data.categoryCode}`);
return category; return category;
@ -190,23 +220,49 @@ export class CommonCodeService {
try { try {
// 디버깅: 받은 데이터 로그 // 디버깅: 받은 데이터 로그
logger.info(`카테고리 수정 데이터:`, { categoryCode, data }); logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
const category = await prisma.code_category.update({
where: { category_code: categoryCode }, // 동적 UPDATE 쿼리 생성
data: { const updateFields: string[] = [
category_name: data.categoryName, "updated_by = $1",
category_name_eng: data.categoryNameEng, "updated_date = NOW()",
description: data.description, ];
sort_order: data.sortOrder, const values: any[] = [updatedBy];
is_active: let paramIndex = 2;
if (data.categoryName !== undefined) {
updateFields.push(`category_name = $${paramIndex++}`);
values.push(data.categoryName);
}
if (data.categoryNameEng !== undefined) {
updateFields.push(`category_name_eng = $${paramIndex++}`);
values.push(data.categoryNameEng);
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.sortOrder !== undefined) {
updateFields.push(`sort_order = $${paramIndex++}`);
values.push(data.sortOrder);
}
if (data.isActive !== undefined) {
const activeValue =
typeof data.isActive === "boolean" typeof data.isActive === "boolean"
? data.isActive ? data.isActive
? "Y" ? "Y"
: "N" : "N"
: data.isActive, // boolean이면 "Y"/"N"으로 변환 : data.isActive;
updated_by: updatedBy, updateFields.push(`is_active = $${paramIndex++}`);
updated_date: new Date(), values.push(activeValue);
}, }
});
const category = await queryOne<CodeCategory>(
`UPDATE code_category
SET ${updateFields.join(", ")}
WHERE category_code = $${paramIndex}
RETURNING *`,
[...values, categoryCode]
);
logger.info(`카테고리 수정 완료: ${categoryCode}`); logger.info(`카테고리 수정 완료: ${categoryCode}`);
return category; return category;
@ -221,9 +277,9 @@ export class CommonCodeService {
*/ */
async deleteCategory(categoryCode: string) { async deleteCategory(categoryCode: string) {
try { try {
await prisma.code_category.delete({ await query(`DELETE FROM code_category WHERE category_code = $1`, [
where: { category_code: categoryCode }, categoryCode,
}); ]);
logger.info(`카테고리 삭제 완료: ${categoryCode}`); logger.info(`카테고리 삭제 완료: ${categoryCode}`);
} catch (error) { } catch (error) {
@ -241,19 +297,23 @@ export class CommonCodeService {
createdBy: string createdBy: string
) { ) {
try { try {
const code = await prisma.code_info.create({ const code = await queryOne<CodeInfo>(
data: { `INSERT INTO code_info
code_category: categoryCode, (code_category, code_value, code_name, code_name_eng, description, sort_order,
code_value: data.codeValue, is_active, created_by, updated_by, created_date, updated_date)
code_name: data.codeName, VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, NOW(), NOW())
code_name_eng: data.codeNameEng, RETURNING *`,
description: data.description, [
sort_order: data.sortOrder || 0, categoryCode,
is_active: "Y", data.codeValue,
created_by: createdBy, data.codeName,
updated_by: createdBy, data.codeNameEng || null,
}, data.description || null,
}); data.sortOrder || 0,
createdBy,
createdBy,
]
);
logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`); logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`);
return code; return code;
@ -278,28 +338,49 @@ export class CommonCodeService {
try { try {
// 디버깅: 받은 데이터 로그 // 디버깅: 받은 데이터 로그
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data }); logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
const code = await prisma.code_info.update({
where: { // 동적 UPDATE 쿼리 생성
code_category_code_value: { const updateFields: string[] = [
code_category: categoryCode, "updated_by = $1",
code_value: codeValue, "updated_date = NOW()",
}, ];
}, const values: any[] = [updatedBy];
data: { let paramIndex = 2;
code_name: data.codeName,
code_name_eng: data.codeNameEng, if (data.codeName !== undefined) {
description: data.description, updateFields.push(`code_name = $${paramIndex++}`);
sort_order: data.sortOrder, values.push(data.codeName);
is_active: }
if (data.codeNameEng !== undefined) {
updateFields.push(`code_name_eng = $${paramIndex++}`);
values.push(data.codeNameEng);
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(data.description);
}
if (data.sortOrder !== undefined) {
updateFields.push(`sort_order = $${paramIndex++}`);
values.push(data.sortOrder);
}
if (data.isActive !== undefined) {
const activeValue =
typeof data.isActive === "boolean" typeof data.isActive === "boolean"
? data.isActive ? data.isActive
? "Y" ? "Y"
: "N" : "N"
: data.isActive, // boolean이면 "Y"/"N"으로 변환 : data.isActive;
updated_by: updatedBy, updateFields.push(`is_active = $${paramIndex++}`);
updated_date: new Date(), values.push(activeValue);
}, }
});
const code = await queryOne<CodeInfo>(
`UPDATE code_info
SET ${updateFields.join(", ")}
WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}
RETURNING *`,
[...values, categoryCode, codeValue]
);
logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`); logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`);
return code; return code;
@ -314,14 +395,10 @@ export class CommonCodeService {
*/ */
async deleteCode(categoryCode: string, codeValue: string) { async deleteCode(categoryCode: string, codeValue: string) {
try { try {
await prisma.code_info.delete({ await query(
where: { `DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`,
code_category_code_value: { [categoryCode, codeValue]
code_category: categoryCode, );
code_value: codeValue,
},
},
});
logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`); logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`);
} catch (error) { } catch (error) {
@ -335,19 +412,18 @@ export class CommonCodeService {
*/ */
async getCodeOptions(categoryCode: string) { async getCodeOptions(categoryCode: string) {
try { try {
const codes = await prisma.code_info.findMany({ const codes = await query<{
where: { code_value: string;
code_category: categoryCode, code_name: string;
is_active: "Y", code_name_eng: string | null;
}, sort_order: number;
select: { }>(
code_value: true, `SELECT code_value, code_name, code_name_eng, sort_order
code_name: true, FROM code_info
code_name_eng: true, WHERE code_category = $1 AND is_active = 'Y'
sort_order: true, ORDER BY sort_order ASC, code_value ASC`,
}, [categoryCode]
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }], );
});
const options = codes.map((code) => ({ const options = codes.map((code) => ({
value: code.code_value, value: code.code_value,
@ -373,13 +449,14 @@ export class CommonCodeService {
) { ) {
try { try {
// 먼저 존재하는 코드들을 확인 // 먼저 존재하는 코드들을 확인
const existingCodes = await prisma.code_info.findMany({ const codeValues = codes.map((c) => c.codeValue);
where: { const placeholders = codeValues.map((_, i) => `$${i + 2}`).join(", ");
code_category: categoryCode,
code_value: { in: codes.map((c) => c.codeValue) }, const existingCodes = await query<{ code_value: string }>(
}, `SELECT code_value FROM code_info
select: { code_value: true }, WHERE code_category = $1 AND code_value IN (${placeholders})`,
}); [categoryCode, ...codeValues]
);
const existingCodeValues = existingCodes.map((c) => c.code_value); const existingCodeValues = existingCodes.map((c) => c.code_value);
const validCodes = codes.filter((c) => const validCodes = codes.filter((c) =>
@ -392,23 +469,17 @@ export class CommonCodeService {
); );
} }
const updatePromises = validCodes.map(({ codeValue, sortOrder }) => // 트랜잭션으로 업데이트
prisma.code_info.update({ await transaction(async (client) => {
where: { for (const { codeValue, sortOrder } of validCodes) {
code_category_code_value: { await client.query(
code_category: categoryCode, `UPDATE code_info
code_value: codeValue, SET sort_order = $1, updated_by = $2, updated_date = NOW()
}, WHERE code_category = $3 AND code_value = $4`,
}, [sortOrder, updatedBy, categoryCode, codeValue]
data: {
sort_order: sortOrder,
updated_by: updatedBy,
updated_date: new Date(),
},
})
); );
}
await Promise.all(updatePromises); });
const skippedCodes = codes.filter( const skippedCodes = codes.filter(
(c) => !existingCodeValues.includes(c.codeValue) (c) => !existingCodeValues.includes(c.codeValue)
@ -460,18 +531,38 @@ export class CommonCodeService {
break; break;
} }
// 수정 시 자기 자신 제외 // SQL 쿼리 생성
if (excludeCategoryCode) { let sql = "";
whereCondition.category_code = { const values: any[] = [];
...whereCondition.category_code, let paramIndex = 1;
not: excludeCategoryCode,
}; switch (field) {
case "categoryCode":
sql = `SELECT category_code FROM code_category WHERE category_code = $${paramIndex++}`;
values.push(trimmedValue);
break;
case "categoryName":
sql = `SELECT category_code FROM code_category WHERE category_name = $${paramIndex++}`;
values.push(trimmedValue);
break;
case "categoryNameEng":
sql = `SELECT category_code FROM code_category WHERE category_name_eng = $${paramIndex++}`;
values.push(trimmedValue);
break;
} }
const existingCategory = await prisma.code_category.findFirst({ // 수정 시 자기 자신 제외
where: whereCondition, if (excludeCategoryCode) {
select: { category_code: true }, sql += ` AND category_code != $${paramIndex++}`;
}); values.push(excludeCategoryCode);
}
sql += ` LIMIT 1`;
const existingCategory = await queryOne<{ category_code: string }>(
sql,
values
);
const isDuplicate = !!existingCategory; const isDuplicate = !!existingCategory;
const fieldNames = { const fieldNames = {
@ -527,18 +618,36 @@ export class CommonCodeService {
break; break;
} }
// 수정 시 자기 자신 제외 // SQL 쿼리 생성
if (excludeCodeValue) { let sql =
whereCondition.code_value = { "SELECT code_value FROM code_info WHERE code_category = $1 AND ";
...whereCondition.code_value, const values: any[] = [categoryCode];
not: excludeCodeValue, let paramIndex = 2;
};
switch (field) {
case "codeValue":
sql += `code_value = $${paramIndex++}`;
values.push(trimmedValue);
break;
case "codeName":
sql += `code_name = $${paramIndex++}`;
values.push(trimmedValue);
break;
case "codeNameEng":
sql += `code_name_eng = $${paramIndex++}`;
values.push(trimmedValue);
break;
} }
const existingCode = await prisma.code_info.findFirst({ // 수정 시 자기 자신 제외
where: whereCondition, if (excludeCodeValue) {
select: { code_value: true }, sql += ` AND code_value != $${paramIndex++}`;
}); values.push(excludeCodeValue);
}
sql += ` LIMIT 1`;
const existingCode = await queryOne<{ code_value: string }>(sql, values);
const isDuplicate = !!existingCode; const isDuplicate = !!existingCode;
const fieldNames = { const fieldNames = {

View File

@ -1,6 +1,4 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
const prisma = new PrismaClient();
export interface ComponentStandardData { export interface ComponentStandardData {
component_code: string; component_code: string;
@ -49,49 +47,78 @@ class ComponentStandardService {
offset = 0, offset = 0,
} = params; } = params;
const where: any = {}; const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 활성화 상태 필터 // 활성화 상태 필터
if (active) { if (active) {
where.is_active = active; whereConditions.push(`is_active = $${paramIndex++}`);
values.push(active);
} }
// 카테고리 필터 // 카테고리 필터
if (category && category !== "all") { if (category && category !== "all") {
where.category = category; whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
} }
// 공개 여부 필터 // 공개 여부 필터
if (is_public) { if (is_public) {
where.is_public = is_public; whereConditions.push(`is_public = $${paramIndex++}`);
values.push(is_public);
} }
// 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트) // 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트)
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(
`(is_public = 'Y' OR company_code = $${paramIndex++})`
);
values.push(company_code);
} }
// 검색 조건 // 검색 조건
if (search) { if (search) {
where.OR = [ whereConditions.push(
...(where.OR || []), `(component_name ILIKE $${paramIndex} OR component_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
{ component_name: { contains: search, mode: "insensitive" } }, );
{ component_name_eng: { contains: search, mode: "insensitive" } }, values.push(`%${search}%`);
{ description: { contains: search, mode: "insensitive" } }, paramIndex++;
];
} }
const orderBy: any = {}; const whereClause =
orderBy[sort] = order; whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const components = await prisma.component_standards.findMany({ // 정렬 컬럼 검증 (SQL 인젝션 방지)
where, const validSortColumns = [
orderBy, "sort_order",
take: limit, "component_name",
skip: offset, "category",
}); "created_date",
"updated_date",
];
const sortColumn = validSortColumns.includes(sort) ? sort : "sort_order";
const sortOrder = order === "desc" ? "DESC" : "ASC";
const total = await prisma.component_standards.count({ where }); // 컴포넌트 조회
const components = await query<any>(
`SELECT * FROM component_standards
${whereClause}
ORDER BY ${sortColumn} ${sortOrder}
${limit ? `LIMIT $${paramIndex++}` : ""}
${limit ? `OFFSET $${paramIndex++}` : ""}`,
limit ? [...values, limit, offset] : values
);
// 전체 개수 조회
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
values
);
const total = parseInt(countResult?.count || "0");
return { return {
components, components,
@ -105,9 +132,10 @@ class ComponentStandardService {
* *
*/ */
async getComponent(component_code: string) { async getComponent(component_code: string) {
const component = await prisma.component_standards.findUnique({ const component = await queryOne<any>(
where: { component_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [component_code]
);
if (!component) { if (!component) {
throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`); throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`);
@ -121,9 +149,10 @@ class ComponentStandardService {
*/ */
async createComponent(data: ComponentStandardData) { async createComponent(data: ComponentStandardData) {
// 중복 코드 확인 // 중복 코드 확인
const existing = await prisma.component_standards.findUnique({ const existing = await queryOne<any>(
where: { component_code: data.component_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [data.component_code]
);
if (existing) { if (existing) {
throw new Error( throw new Error(
@ -138,13 +167,31 @@ class ComponentStandardService {
delete (createData as any).active; delete (createData as any).active;
} }
const component = await prisma.component_standards.create({ const component = await queryOne<any>(
data: { `INSERT INTO component_standards
...createData, (component_code, component_name, component_name_eng, description, category,
created_date: new Date(), icon_name, default_size, component_config, preview_image, sort_order,
updated_date: new Date(), is_active, is_public, company_code, created_by, updated_by, created_date, updated_date)
}, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())
}); RETURNING *`,
[
createData.component_code,
createData.component_name,
createData.component_name_eng || null,
createData.description || null,
createData.category,
createData.icon_name || null,
createData.default_size || null,
createData.component_config,
createData.preview_image || null,
createData.sort_order || 0,
createData.is_active || "Y",
createData.is_public || "N",
createData.company_code,
createData.created_by || null,
createData.updated_by || null,
]
);
return component; return component;
} }
@ -165,13 +212,41 @@ class ComponentStandardService {
delete (updateData as any).active; delete (updateData as any).active;
} }
const component = await prisma.component_standards.update({ // 동적 UPDATE 쿼리 생성
where: { component_code }, const updateFields: string[] = ["updated_date = NOW()"];
data: { const values: any[] = [];
...updateData, let paramIndex = 1;
updated_date: new Date(),
}, const fieldMapping: { [key: string]: string } = {
}); component_name: "component_name",
component_name_eng: "component_name_eng",
description: "description",
category: "category",
icon_name: "icon_name",
default_size: "default_size",
component_config: "component_config",
preview_image: "preview_image",
sort_order: "sort_order",
is_active: "is_active",
is_public: "is_public",
company_code: "company_code",
updated_by: "updated_by",
};
for (const [key, dbField] of Object.entries(fieldMapping)) {
if (key in updateData) {
updateFields.push(`${dbField} = $${paramIndex++}`);
values.push((updateData as any)[key]);
}
}
const component = await queryOne<any>(
`UPDATE component_standards
SET ${updateFields.join(", ")}
WHERE component_code = $${paramIndex}
RETURNING *`,
[...values, component_code]
);
return component; return component;
} }
@ -182,9 +257,9 @@ class ComponentStandardService {
async deleteComponent(component_code: string) { async deleteComponent(component_code: string) {
const existing = await this.getComponent(component_code); const existing = await this.getComponent(component_code);
await prisma.component_standards.delete({ await query(`DELETE FROM component_standards WHERE component_code = $1`, [
where: { component_code }, component_code,
}); ]);
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` }; return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
} }
@ -195,14 +270,16 @@ class ComponentStandardService {
async updateSortOrder( async updateSortOrder(
updates: Array<{ component_code: string; sort_order: number }> updates: Array<{ component_code: string; sort_order: number }>
) { ) {
const transactions = updates.map(({ component_code, sort_order }) => await transaction(async (client) => {
prisma.component_standards.update({ for (const { component_code, sort_order } of updates) {
where: { component_code }, await client.query(
data: { sort_order, updated_date: new Date() }, `UPDATE component_standards
}) SET sort_order = $1, updated_date = NOW()
WHERE component_code = $2`,
[sort_order, component_code]
); );
}
await prisma.$transaction(transactions); });
return { message: "정렬 순서가 업데이트되었습니다." }; return { message: "정렬 순서가 업데이트되었습니다." };
} }
@ -218,33 +295,38 @@ class ComponentStandardService {
const source = await this.getComponent(source_code); const source = await this.getComponent(source_code);
// 새 코드 중복 확인 // 새 코드 중복 확인
const existing = await prisma.component_standards.findUnique({ const existing = await queryOne<any>(
where: { component_code: new_code }, `SELECT * FROM component_standards WHERE component_code = $1`,
}); [new_code]
);
if (existing) { if (existing) {
throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`); throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`);
} }
const component = await prisma.component_standards.create({ const component = await queryOne<any>(
data: { `INSERT INTO component_standards
component_code: new_code, (component_code, component_name, component_name_eng, description, category,
component_name: new_name, icon_name, default_size, component_config, preview_image, sort_order,
component_name_eng: source?.component_name_eng, is_active, is_public, company_code, created_date, updated_date)
description: source?.description, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
category: source?.category, RETURNING *`,
icon_name: source?.icon_name, [
default_size: source?.default_size as any, new_code,
component_config: source?.component_config as any, new_name,
preview_image: source?.preview_image, source?.component_name_eng,
sort_order: source?.sort_order, source?.description,
is_active: source?.is_active, source?.category,
is_public: source?.is_public, source?.icon_name,
company_code: source?.company_code || "DEFAULT", source?.default_size,
created_date: new Date(), source?.component_config,
updated_date: new Date(), source?.preview_image,
}, source?.sort_order,
}); source?.is_active,
source?.is_public,
source?.company_code || "DEFAULT",
]
);
return component; return component;
} }
@ -253,19 +335,20 @@ class ComponentStandardService {
* *
*/ */
async getCategories(company_code?: string) { async getCategories(company_code?: string) {
const where: any = { const whereConditions: string[] = ["is_active = 'Y'"];
is_active: "Y", const values: any[] = [];
};
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
values.push(company_code);
} }
const categories = await prisma.component_standards.findMany({ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
where,
select: { category: true }, const categories = await query<{ category: string }>(
distinct: ["category"], `SELECT DISTINCT category FROM component_standards ${whereClause} ORDER BY category`,
}); values
);
return categories return categories
.map((item) => item.category) .map((item) => item.category)
@ -276,36 +359,48 @@ class ComponentStandardService {
* *
*/ */
async getStatistics(company_code?: string) { async getStatistics(company_code?: string) {
const where: any = { const whereConditions: string[] = ["is_active = 'Y'"];
is_active: "Y", const values: any[] = [];
};
if (company_code) { if (company_code) {
where.OR = [{ is_public: "Y" }, { company_code }]; whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
values.push(company_code);
} }
const total = await prisma.component_standards.count({ where }); const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const byCategory = await prisma.component_standards.groupBy({ // 전체 개수
by: ["category"], const totalResult = await queryOne<{ count: string }>(
where, `SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
_count: { category: true }, values
}); );
const total = parseInt(totalResult?.count || "0");
const byStatus = await prisma.component_standards.groupBy({ // 카테고리별 집계
by: ["is_active"], const byCategory = await query<{ category: string; count: string }>(
_count: { is_active: true }, `SELECT category, COUNT(*) as count
}); FROM component_standards
${whereClause}
GROUP BY category`,
values
);
// 상태별 집계
const byStatus = await query<{ is_active: string; count: string }>(
`SELECT is_active, COUNT(*) as count
FROM component_standards
GROUP BY is_active`
);
return { return {
total, total,
byCategory: byCategory.map((item) => ({ byCategory: byCategory.map((item) => ({
category: item.category, category: item.category,
count: item._count.category, count: parseInt(item.count),
})), })),
byStatus: byStatus.map((item) => ({ byStatus: byStatus.map((item) => ({
status: item.is_active, status: item.is_active,
count: item._count.is_active, count: parseInt(item.count),
})), })),
}; };
} }
@ -317,16 +412,21 @@ class ComponentStandardService {
component_code: string, component_code: string,
company_code?: string company_code?: string
): Promise<boolean> { ): Promise<boolean> {
const whereClause: any = { component_code }; const whereConditions: string[] = ["component_code = $1"];
const values: any[] = [component_code];
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가 // 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
if (company_code && company_code !== "*") { if (company_code && company_code !== "*") {
whereClause.company_code = company_code; whereConditions.push("company_code = $2");
values.push(company_code);
} }
const existingComponent = await prisma.component_standards.findFirst({ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
where: whereClause,
}); const existingComponent = await queryOne<any>(
`SELECT * FROM component_standards ${whereClause} LIMIT 1`,
values
);
return !!existingComponent; return !!existingComponent;
} }

View File

@ -1,5 +1,4 @@
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용 import { query, queryOne } from "../database/db";
import prisma = require("../config/database");
export interface ControlCondition { export interface ControlCondition {
id: string; id: string;
@ -82,9 +81,10 @@ export class DataflowControlService {
}); });
// 관계도 정보 조회 // 관계도 정보 조회
const diagram = await prisma.dataflow_diagrams.findUnique({ const diagram = await queryOne<any>(
where: { diagram_id: diagramId }, `SELECT * FROM dataflow_diagrams WHERE diagram_id = $1`,
}); [diagramId]
);
if (!diagram) { if (!diagram) {
return { return {
@ -527,9 +527,9 @@ export class DataflowControlService {
} }
// 대상 테이블에서 조건에 맞는 데이터 조회 // 대상 테이블에서 조건에 맞는 데이터 조회
const queryResult = await prisma.$queryRawUnsafe( const queryResult = await query<Record<string, any>>(
`SELECT ${condition.field} FROM ${tableName} WHERE ${condition.field} = $1 LIMIT 1`, `SELECT ${condition.field} FROM ${tableName} WHERE ${condition.field} = $1 LIMIT 1`,
condition.value [condition.value]
); );
dataToCheck = dataToCheck =
@ -758,14 +758,14 @@ export class DataflowControlService {
try { try {
// 동적 테이블 INSERT 실행 // 동적 테이블 INSERT 실행
const result = await prisma.$executeRawUnsafe( const placeholders = Object.keys(insertData)
` .map((_, i) => `$${i + 1}`)
INSERT INTO ${targetTable} (${Object.keys(insertData).join(", ")}) .join(", ");
VALUES (${Object.keys(insertData)
.map(() => "?") const result = await query(
.join(", ")}) `INSERT INTO ${targetTable} (${Object.keys(insertData).join(", ")})
`, VALUES (${placeholders})`,
...Object.values(insertData) Object.values(insertData)
); );
results.push({ results.push({
@ -878,10 +878,7 @@ export class DataflowControlService {
); );
console.log(`📊 쿼리 파라미터:`, allValues); console.log(`📊 쿼리 파라미터:`, allValues);
const result = await prisma.$executeRawUnsafe( const result = await query(updateQuery, allValues);
updateQuery,
...allValues
);
console.log( console.log(
`✅ UPDATE 성공 (${i + 1}/${action.fieldMappings.length}):`, `✅ UPDATE 성공 (${i + 1}/${action.fieldMappings.length}):`,
@ -1024,10 +1021,7 @@ export class DataflowControlService {
console.log(`🚀 실행할 쿼리:`, deleteQuery); console.log(`🚀 실행할 쿼리:`, deleteQuery);
console.log(`📊 쿼리 파라미터:`, whereValues); console.log(`📊 쿼리 파라미터:`, whereValues);
const result = await prisma.$executeRawUnsafe( const result = await query(deleteQuery, whereValues);
deleteQuery,
...whereValues
);
console.log(`✅ DELETE 성공:`, { console.log(`✅ DELETE 성공:`, {
table: tableName, table: tableName,
@ -1080,18 +1074,15 @@ export class DataflowControlService {
columnName: string columnName: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
const result = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>( const result = await query<{ exists: boolean }>(
` `SELECT EXISTS (
SELECT EXISTS (
SELECT 1 SELECT 1
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND table_schema = 'public' AND table_schema = 'public'
) as exists ) as exists`,
`, [tableName, columnName]
tableName,
columnName
); );
return result[0]?.exists || false; return result[0]?.exists || false;

View File

@ -1,5 +1,4 @@
import { Prisma } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import prisma from "../config/database";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
// 타입 정의 // 타입 정의
@ -43,41 +42,41 @@ export const getDataflowDiagrams = async (
try { try {
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 검색 조건 구성 const whereConditions: string[] = [];
const whereClause: { const values: any[] = [];
company_code?: string; let paramIndex = 1;
diagram_name?: {
contains: string;
mode: "insensitive";
};
} = {};
// company_code가 '*'가 아닌 경우에만 필터링 // company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause.company_code = companyCode; whereConditions.push(`company_code = $${paramIndex++}`);
values.push(companyCode);
} }
if (searchTerm) { if (searchTerm) {
whereClause.diagram_name = { whereConditions.push(`diagram_name ILIKE $${paramIndex++}`);
contains: searchTerm, values.push(`%${searchTerm}%`);
mode: "insensitive",
};
} }
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 총 개수 조회 // 총 개수 조회
const total = await prisma.dataflow_diagrams.count({ const countResult = await queryOne<{ count: string }>(
where: whereClause, `SELECT COUNT(*) as count FROM dataflow_diagrams ${whereClause}`,
}); values
);
const total = parseInt(countResult?.count || "0");
// 데이터 조회 // 데이터 조회
const diagrams = await prisma.dataflow_diagrams.findMany({ const diagrams = await query<any>(
where: whereClause, `SELECT * FROM dataflow_diagrams
orderBy: { ${whereClause}
updated_at: "desc", ORDER BY updated_at DESC
}, LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
skip: offset, [...values, size, offset]
take: size, );
});
const totalPages = Math.ceil(total / size); const totalPages = Math.ceil(total / size);
@ -104,21 +103,21 @@ export const getDataflowDiagramById = async (
companyCode: string companyCode: string
) => { ) => {
try { try {
const whereClause: { const whereConditions: string[] = ["diagram_id = $1"];
diagram_id: number; const values: any[] = [diagramId];
company_code?: string;
} = {
diagram_id: diagramId,
};
// company_code가 '*'가 아닌 경우에만 필터링 // company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause.company_code = companyCode; whereConditions.push("company_code = $2");
values.push(companyCode);
} }
const diagram = await prisma.dataflow_diagrams.findFirst({ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
where: whereClause,
}); const diagram = await queryOne<any>(
`SELECT * FROM dataflow_diagrams ${whereClause} LIMIT 1`,
values
);
return diagram; return diagram;
} catch (error) { } catch (error) {
@ -134,23 +133,24 @@ export const createDataflowDiagram = async (
data: CreateDataflowDiagramData data: CreateDataflowDiagramData
) => { ) => {
try { try {
const newDiagram = await prisma.dataflow_diagrams.create({ const newDiagram = await queryOne<any>(
data: { `INSERT INTO dataflow_diagrams
diagram_name: data.diagram_name, (diagram_name, relationships, node_positions, category, control, plan,
relationships: data.relationships as Prisma.InputJsonValue, company_code, created_by, updated_by, created_at, updated_at)
node_positions: data.node_positions as VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
| Prisma.InputJsonValue RETURNING *`,
| undefined, [
category: data.category data.diagram_name,
? (data.category as Prisma.InputJsonValue) JSON.stringify(data.relationships),
: undefined, data.node_positions ? JSON.stringify(data.node_positions) : null,
control: data.control as Prisma.InputJsonValue | undefined, data.category ? JSON.stringify(data.category) : null,
plan: data.plan as Prisma.InputJsonValue | undefined, data.control ? JSON.stringify(data.control) : null,
company_code: data.company_code, data.plan ? JSON.stringify(data.plan) : null,
created_by: data.created_by, data.company_code,
updated_by: data.updated_by, data.created_by,
}, data.updated_by,
}); ]
);
return newDiagram; return newDiagram;
} catch (error) { } catch (error) {
@ -173,21 +173,18 @@ export const updateDataflowDiagram = async (
); );
// 먼저 해당 관계도가 존재하는지 확인 // 먼저 해당 관계도가 존재하는지 확인
const whereClause: { const whereConditions: string[] = ["diagram_id = $1"];
diagram_id: number; const checkValues: any[] = [diagramId];
company_code?: string;
} = {
diagram_id: diagramId,
};
// company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause.company_code = companyCode; whereConditions.push("company_code = $2");
checkValues.push(companyCode);
} }
const existingDiagram = await prisma.dataflow_diagrams.findFirst({ const existingDiagram = await queryOne<any>(
where: whereClause, `SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
}); checkValues
);
logger.info( logger.info(
`기존 관계도 조회 결과:`, `기존 관계도 조회 결과:`,
@ -201,36 +198,45 @@ export const updateDataflowDiagram = async (
return null; return null;
} }
// 업데이트 실행 // 동적 UPDATE 쿼리 생성
const updatedDiagram = await prisma.dataflow_diagrams.update({ const updateFields: string[] = ["updated_by = $1", "updated_at = NOW()"];
where: { const values: any[] = [data.updated_by];
diagram_id: diagramId, let paramIndex = 2;
},
data: { if (data.diagram_name) {
...(data.diagram_name && { diagram_name: data.diagram_name }), updateFields.push(`diagram_name = $${paramIndex++}`);
...(data.relationships && { values.push(data.diagram_name);
relationships: data.relationships as Prisma.InputJsonValue, }
}), if (data.relationships) {
...(data.node_positions !== undefined && { updateFields.push(`relationships = $${paramIndex++}`);
node_positions: data.node_positions values.push(JSON.stringify(data.relationships));
? (data.node_positions as Prisma.InputJsonValue) }
: Prisma.JsonNull, if (data.node_positions !== undefined) {
}), updateFields.push(`node_positions = $${paramIndex++}`);
...(data.category !== undefined && { values.push(
category: data.category data.node_positions ? JSON.stringify(data.node_positions) : null
? (data.category as Prisma.InputJsonValue) );
: undefined, }
}), if (data.category !== undefined) {
...(data.control !== undefined && { updateFields.push(`category = $${paramIndex++}`);
control: data.control as Prisma.InputJsonValue | undefined, values.push(data.category ? JSON.stringify(data.category) : null);
}), }
...(data.plan !== undefined && { if (data.control !== undefined) {
plan: data.plan as Prisma.InputJsonValue | undefined, updateFields.push(`control = $${paramIndex++}`);
}), values.push(data.control ? JSON.stringify(data.control) : null);
updated_by: data.updated_by, }
updated_at: new Date(), if (data.plan !== undefined) {
}, updateFields.push(`plan = $${paramIndex++}`);
}); values.push(data.plan ? JSON.stringify(data.plan) : null);
}
const updatedDiagram = await queryOne<any>(
`UPDATE dataflow_diagrams
SET ${updateFields.join(", ")}
WHERE diagram_id = $${paramIndex}
RETURNING *`,
[...values, diagramId]
);
return updatedDiagram; return updatedDiagram;
} catch (error) { } catch (error) {
@ -248,32 +254,27 @@ export const deleteDataflowDiagram = async (
) => { ) => {
try { try {
// 먼저 해당 관계도가 존재하는지 확인 // 먼저 해당 관계도가 존재하는지 확인
const whereClause: { const whereConditions: string[] = ["diagram_id = $1"];
diagram_id: number; const values: any[] = [diagramId];
company_code?: string;
} = {
diagram_id: diagramId,
};
// company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause.company_code = companyCode; whereConditions.push("company_code = $2");
values.push(companyCode);
} }
const existingDiagram = await prisma.dataflow_diagrams.findFirst({ const existingDiagram = await queryOne<any>(
where: whereClause, `SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
}); values
);
if (!existingDiagram) { if (!existingDiagram) {
return false; return false;
} }
// 삭제 실행 // 삭제 실행
await prisma.dataflow_diagrams.delete({ await query(`DELETE FROM dataflow_diagrams WHERE diagram_id = $1`, [
where: { diagramId,
diagram_id: diagramId, ]);
},
});
return true; return true;
} catch (error) { } catch (error) {
@ -293,21 +294,18 @@ export const copyDataflowDiagram = async (
) => { ) => {
try { try {
// 원본 관계도 조회 // 원본 관계도 조회
const whereClause: { const whereConditions: string[] = ["diagram_id = $1"];
diagram_id: number; const values: any[] = [diagramId];
company_code?: string;
} = {
diagram_id: diagramId,
};
// company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause.company_code = companyCode; whereConditions.push("company_code = $2");
values.push(companyCode);
} }
const originalDiagram = await prisma.dataflow_diagrams.findFirst({ const originalDiagram = await queryOne<any>(
where: whereClause, `SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
}); values
);
if (!originalDiagram) { if (!originalDiagram) {
return null; return null;
@ -325,28 +323,19 @@ export const copyDataflowDiagram = async (
: originalDiagram.diagram_name; : originalDiagram.diagram_name;
// 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기 // 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기
const copyWhereClause: { const copyWhereConditions: string[] = ["diagram_name LIKE $1"];
diagram_name: { const copyValues: any[] = [`${baseName}%`];
startsWith: string;
};
company_code?: string;
} = {
diagram_name: {
startsWith: baseName,
},
};
// company_code가 '*'가 아닌 경우에만 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
copyWhereClause.company_code = companyCode; copyWhereConditions.push("company_code = $2");
copyValues.push(companyCode);
} }
const existingCopies = await prisma.dataflow_diagrams.findMany({ const existingCopies = await query<{ diagram_name: string }>(
where: copyWhereClause, `SELECT diagram_name FROM dataflow_diagrams
select: { WHERE ${copyWhereConditions.join(" AND ")}`,
diagram_name: true, copyValues
}, );
});
let maxNumber = 0; let maxNumber = 0;
existingCopies.forEach((copy) => { existingCopies.forEach((copy) => {
@ -363,19 +352,24 @@ export const copyDataflowDiagram = async (
} }
// 새로운 관계도 생성 // 새로운 관계도 생성
const copiedDiagram = await prisma.dataflow_diagrams.create({ const copiedDiagram = await queryOne<any>(
data: { `INSERT INTO dataflow_diagrams
diagram_name: copyName, (diagram_name, relationships, node_positions, category, company_code,
relationships: originalDiagram.relationships as Prisma.InputJsonValue, created_by, updated_by, created_at, updated_at)
node_positions: originalDiagram.node_positions VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
? (originalDiagram.node_positions as Prisma.InputJsonValue) RETURNING *`,
: Prisma.JsonNull, [
category: originalDiagram.category || undefined, copyName,
company_code: companyCode, JSON.stringify(originalDiagram.relationships),
created_by: userId, originalDiagram.node_positions
updated_by: userId, ? JSON.stringify(originalDiagram.node_positions)
}, : null,
}); originalDiagram.category || null,
companyCode,
userId,
userId,
]
);
return copiedDiagram; return copiedDiagram;
} catch (error) { } catch (error) {
@ -390,34 +384,34 @@ export const copyDataflowDiagram = async (
*/ */
export const getAllRelationshipsForButtonControl = async ( export const getAllRelationshipsForButtonControl = async (
companyCode: string companyCode: string
): Promise<Array<{ ): Promise<
Array<{
id: string; id: string;
name: string; name: string;
sourceTable: string; sourceTable: string;
targetTable: string; targetTable: string;
category: string; category: string;
}>> => { }>
> => {
try { try {
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`); logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
// dataflow_diagrams 테이블에서 관계도들을 조회 // dataflow_diagrams 테이블에서 관계도들을 조회
const diagrams = await prisma.dataflow_diagrams.findMany({ const diagrams = await query<{
where: { diagram_id: number;
company_code: companyCode, diagram_name: string;
}, relationships: any;
select: { }>(
diagram_id: true, `SELECT diagram_id, diagram_name, relationships
diagram_name: true, FROM dataflow_diagrams
relationships: true, WHERE company_code = $1
}, ORDER BY updated_at DESC`,
orderBy: { [companyCode]
updated_at: "desc", );
},
});
const allRelationships = diagrams.map((diagram) => { const allRelationships = diagrams.map((diagram) => {
// relationships 구조에서 테이블 정보 추출 // relationships 구조에서 테이블 정보 추출
const relationships = diagram.relationships as any || {}; const relationships = (diagram.relationships as any) || {};
// 테이블 정보 추출 // 테이블 정보 추출
let sourceTable = ""; let sourceTable = "";

View File

@ -1,8 +1,6 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
const prisma = new PrismaClient();
// 테이블 관계 생성 데이터 타입 // 테이블 관계 생성 데이터 타입
interface CreateTableRelationshipData { interface CreateTableRelationshipData {
diagramId?: number; // 기존 관계도에 추가하는 경우 diagramId?: number; // 기존 관계도에 추가하는 경우
@ -45,33 +43,36 @@ export class DataflowService {
if (!diagramId) { if (!diagramId) {
// 새로운 관계도인 경우, 새로운 diagram_id 생성 // 새로운 관계도인 경우, 새로운 diagram_id 생성
// 현재 최대 diagram_id + 1 // 현재 최대 diagram_id + 1
const maxDiagramId = await prisma.table_relationships.findFirst({ const maxDiagramId = await queryOne<{ diagram_id: number }>(
where: { `SELECT diagram_id FROM table_relationships
company_code: data.companyCode, WHERE company_code = $1
}, ORDER BY diagram_id DESC
orderBy: { LIMIT 1`,
diagram_id: "desc", [data.companyCode]
}, );
select: {
diagram_id: true,
},
});
diagramId = (maxDiagramId?.diagram_id || 0) + 1; diagramId = (maxDiagramId?.diagram_id || 0) + 1;
} }
// 중복 관계 확인 (같은 diagram_id 내에서) // 중복 관계 확인 (같은 diagram_id 내에서)
const existingRelationship = await prisma.table_relationships.findFirst({ const existingRelationship = await queryOne(
where: { `SELECT * FROM table_relationships
diagram_id: diagramId, WHERE diagram_id = $1
from_table_name: data.fromTableName, AND from_table_name = $2
from_column_name: data.fromColumnName, AND from_column_name = $3
to_table_name: data.toTableName, AND to_table_name = $4
to_column_name: data.toColumnName, AND to_column_name = $5
company_code: data.companyCode, AND company_code = $6
is_active: "Y", AND is_active = 'Y'`,
}, [
}); diagramId,
data.fromTableName,
data.fromColumnName,
data.toTableName,
data.toColumnName,
data.companyCode,
]
);
if (existingRelationship) { if (existingRelationship) {
throw new Error( throw new Error(
@ -80,22 +81,28 @@ export class DataflowService {
} }
// 새 관계 생성 // 새 관계 생성
const relationship = await prisma.table_relationships.create({ const relationship = await queryOne(
data: { `INSERT INTO table_relationships (
diagram_id: diagramId, diagram_id, relationship_name, from_table_name, from_column_name,
relationship_name: data.relationshipName, to_table_name, to_column_name, relationship_type, connection_type,
from_table_name: data.fromTableName, company_code, settings, created_by, updated_by, created_at, updated_at
from_column_name: data.fromColumnName, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now())
to_table_name: data.toTableName, RETURNING *`,
to_column_name: data.toColumnName, [
relationship_type: data.relationshipType, diagramId,
connection_type: data.connectionType, data.relationshipName,
company_code: data.companyCode, data.fromTableName,
settings: data.settings, data.fromColumnName,
created_by: data.createdBy, data.toTableName,
updated_by: data.createdBy, data.toColumnName,
}, data.relationshipType,
}); data.connectionType,
data.companyCode,
JSON.stringify(data.settings),
data.createdBy,
data.createdBy,
]
);
logger.info( logger.info(
`DataflowService: 테이블 관계 생성 완료 - ID: ${relationship.relationship_id}, Diagram ID: ${relationship.diagram_id}` `DataflowService: 테이블 관계 생성 완료 - ID: ${relationship.relationship_id}, Diagram ID: ${relationship.diagram_id}`
@ -117,20 +124,16 @@ export class DataflowService {
); );
// 관리자는 모든 회사의 관계를 볼 수 있음 // 관리자는 모든 회사의 관계를 볼 수 있음
const whereCondition: any = { let queryText = `SELECT * FROM table_relationships WHERE is_active = 'Y'`;
is_active: "Y", const params: any[] = [];
};
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $1`;
params.push(companyCode);
} }
const relationships = await prisma.table_relationships.findMany({ queryText += ` ORDER BY created_date DESC`;
where: whereCondition, const relationships = await query(queryText, params);
orderBy: {
created_date: "desc",
},
});
logger.info( logger.info(
`DataflowService: 테이블 관계 목록 조회 완료 - ${relationships.length}` `DataflowService: 테이블 관계 목록 조회 완료 - ${relationships.length}`
@ -151,19 +154,16 @@ export class DataflowService {
`DataflowService: 테이블 관계 조회 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}` `DataflowService: 테이블 관계 조회 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}`
); );
const whereCondition: any = { let queryText = `SELECT * FROM table_relationships WHERE relationship_id = $1 AND is_active = 'Y'`;
relationship_id: relationshipId, const params: any[] = [relationshipId];
is_active: "Y",
};
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $2`;
params.push(companyCode);
} }
const relationship = await prisma.table_relationships.findFirst({ const relationship = await queryOne(queryText, params);
where: whereCondition,
});
if (relationship) { if (relationship) {
logger.info( logger.info(
@ -206,15 +206,55 @@ export class DataflowService {
} }
// 관계 수정 // 관계 수정
const relationship = await prisma.table_relationships.update({ const updates: string[] = [];
where: { const params: any[] = [];
relationship_id: relationshipId, let paramIndex = 1;
},
data: { if (updateData.relationshipName !== undefined) {
...updateData, updates.push(`relationship_name = $${paramIndex++}`);
updated_date: new Date(), params.push(updateData.relationshipName);
}, }
}); if (updateData.fromTableName !== undefined) {
updates.push(`from_table_name = $${paramIndex++}`);
params.push(updateData.fromTableName);
}
if (updateData.fromColumnName !== undefined) {
updates.push(`from_column_name = $${paramIndex++}`);
params.push(updateData.fromColumnName);
}
if (updateData.toTableName !== undefined) {
updates.push(`to_table_name = $${paramIndex++}`);
params.push(updateData.toTableName);
}
if (updateData.toColumnName !== undefined) {
updates.push(`to_column_name = $${paramIndex++}`);
params.push(updateData.toColumnName);
}
if (updateData.relationshipType !== undefined) {
updates.push(`relationship_type = $${paramIndex++}`);
params.push(updateData.relationshipType);
}
if (updateData.connectionType !== undefined) {
updates.push(`connection_type = $${paramIndex++}`);
params.push(updateData.connectionType);
}
if (updateData.settings !== undefined) {
updates.push(`settings = $${paramIndex++}`);
params.push(JSON.stringify(updateData.settings));
}
updates.push(`updated_by = $${paramIndex++}`);
params.push(updateData.updatedBy);
updates.push(`updated_date = now()`);
params.push(relationshipId);
const relationship = await queryOne(
`UPDATE table_relationships
SET ${updates.join(", ")}
WHERE relationship_id = $${paramIndex}
RETURNING *`,
params
);
logger.info( logger.info(
`DataflowService: 테이블 관계 수정 완료 - ID: ${relationshipId}` `DataflowService: 테이블 관계 수정 완료 - ID: ${relationshipId}`
@ -245,15 +285,12 @@ export class DataflowService {
} }
// 소프트 삭제 (is_active = 'N') // 소프트 삭제 (is_active = 'N')
await prisma.table_relationships.update({ await query(
where: { `UPDATE table_relationships
relationship_id: relationshipId, SET is_active = 'N', updated_date = now()
}, WHERE relationship_id = $1`,
data: { [relationshipId]
is_active: "N", );
updated_date: new Date(),
},
});
logger.info( logger.info(
`DataflowService: 테이블 관계 삭제 완료 - ID: ${relationshipId}` `DataflowService: 테이블 관계 삭제 완료 - ID: ${relationshipId}`
@ -274,22 +311,21 @@ export class DataflowService {
`DataflowService: 테이블별 관계 조회 시작 - 테이블: ${tableName}, 회사코드: ${companyCode}` `DataflowService: 테이블별 관계 조회 시작 - 테이블: ${tableName}, 회사코드: ${companyCode}`
); );
const whereCondition: any = { let queryText = `
OR: [{ from_table_name: tableName }, { to_table_name: tableName }], SELECT * FROM table_relationships
is_active: "Y", WHERE (from_table_name = $1 OR to_table_name = $1)
}; AND is_active = 'Y'
`;
const params: any[] = [tableName];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $2`;
params.push(companyCode);
} }
const relationships = await prisma.table_relationships.findMany({ queryText += ` ORDER BY created_date DESC`;
where: whereCondition, const relationships = await query(queryText, params);
orderBy: {
created_date: "desc",
},
});
logger.info( logger.info(
`DataflowService: 테이블별 관계 조회 완료 - ${relationships.length}` `DataflowService: 테이블별 관계 조회 완료 - ${relationships.length}`
@ -313,22 +349,20 @@ export class DataflowService {
`DataflowService: 연결타입별 관계 조회 시작 - 타입: ${connectionType}, 회사코드: ${companyCode}` `DataflowService: 연결타입별 관계 조회 시작 - 타입: ${connectionType}, 회사코드: ${companyCode}`
); );
const whereCondition: any = { let queryText = `
connection_type: connectionType, SELECT * FROM table_relationships
is_active: "Y", WHERE connection_type = $1 AND is_active = 'Y'
}; `;
const params: any[] = [connectionType];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $2`;
params.push(companyCode);
} }
const relationships = await prisma.table_relationships.findMany({ queryText += ` ORDER BY created_date DESC`;
where: whereCondition, const relationships = await query(queryText, params);
orderBy: {
created_date: "desc",
},
});
logger.info( logger.info(
`DataflowService: 연결타입별 관계 조회 완료 - ${relationships.length}` `DataflowService: 연결타입별 관계 조회 완료 - ${relationships.length}`
@ -349,47 +383,53 @@ export class DataflowService {
`DataflowService: 관계 통계 조회 시작 - 회사코드: ${companyCode}` `DataflowService: 관계 통계 조회 시작 - 회사코드: ${companyCode}`
); );
const whereCondition: any = { let whereClause = `WHERE is_active = 'Y'`;
is_active: "Y", const params: any[] = [];
};
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; whereClause += ` AND company_code = $1`;
params.push(companyCode);
} }
// 전체 관계 수 // 전체 관계 수
const totalCount = await prisma.table_relationships.count({ const totalCountResult = await queryOne<{ count: string }>(
where: whereCondition, `SELECT COUNT(*) as count FROM table_relationships ${whereClause}`,
}); params
);
const totalCount = parseInt(totalCountResult?.count || "0", 10);
// 관계 타입별 통계 // 관계 타입별 통계
const relationshipTypeStats = await prisma.table_relationships.groupBy({ const relationshipTypeStats = await query<{
by: ["relationship_type"], relationship_type: string;
where: whereCondition, count: string;
_count: { }>(
relationship_id: true, `SELECT relationship_type, COUNT(*) as count
}, FROM table_relationships ${whereClause}
}); GROUP BY relationship_type`,
params
);
// 연결 타입별 통계 // 연결 타입별 통계
const connectionTypeStats = await prisma.table_relationships.groupBy({ const connectionTypeStats = await query<{
by: ["connection_type"], connection_type: string;
where: whereCondition, count: string;
_count: { }>(
relationship_id: true, `SELECT connection_type, COUNT(*) as count
}, FROM table_relationships ${whereClause}
}); GROUP BY connection_type`,
params
);
const stats = { const stats = {
totalCount, totalCount,
relationshipTypeStats: relationshipTypeStats.map((stat) => ({ relationshipTypeStats: relationshipTypeStats.map((stat) => ({
type: stat.relationship_type, type: stat.relationship_type,
count: stat._count.relationship_id, count: parseInt(stat.count, 10),
})), })),
connectionTypeStats: connectionTypeStats.map((stat) => ({ connectionTypeStats: connectionTypeStats.map((stat) => ({
type: stat.connection_type, type: stat.connection_type,
count: stat._count.relationship_id, count: parseInt(stat.count, 10),
})), })),
}; };
@ -422,19 +462,25 @@ export class DataflowService {
`DataflowService: 데이터 연결 생성 시작 - 관계ID: ${linkData.relationshipId}` `DataflowService: 데이터 연결 생성 시작 - 관계ID: ${linkData.relationshipId}`
); );
const bridge = await prisma.data_relationship_bridge.create({ const bridge = await queryOne(
data: { `INSERT INTO data_relationship_bridge (
relationship_id: linkData.relationshipId, relationship_id, from_table_name, from_column_name, to_table_name,
from_table_name: linkData.fromTableName, to_column_name, connection_type, company_code, bridge_data,
from_column_name: linkData.fromColumnName, created_by, created_at
to_table_name: linkData.toTableName, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
to_column_name: linkData.toColumnName, RETURNING *`,
connection_type: linkData.connectionType, [
company_code: linkData.companyCode, linkData.relationshipId,
bridge_data: linkData.bridgeData || {}, linkData.fromTableName,
created_by: linkData.createdBy, linkData.fromColumnName,
}, linkData.toTableName,
}); linkData.toColumnName,
linkData.connectionType,
linkData.companyCode,
JSON.stringify(linkData.bridgeData || {}),
linkData.createdBy,
]
);
logger.info( logger.info(
`DataflowService: 데이터 연결 생성 완료 - Bridge ID: ${bridge.bridge_id}` `DataflowService: 데이터 연결 생성 완료 - Bridge ID: ${bridge.bridge_id}`
@ -458,21 +504,20 @@ export class DataflowService {
`DataflowService: 관계별 연결 데이터 조회 시작 - 관계ID: ${relationshipId}` `DataflowService: 관계별 연결 데이터 조회 시작 - 관계ID: ${relationshipId}`
); );
const whereCondition: any = { let queryText = `
relationship_id: relationshipId, SELECT * FROM data_relationship_bridge
is_active: "Y", WHERE relationship_id = $1 AND is_active = 'Y'
}; `;
const params: any[] = [relationshipId];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $2`;
params.push(companyCode);
} }
const linkedData = await prisma.data_relationship_bridge.findMany({ queryText += ` ORDER BY created_at DESC`;
where: whereCondition, const linkedData = await query(queryText, params);
orderBy: { created_at: "desc" },
// include 제거 - relationship 관계가 스키마에 정의되지 않음
});
logger.info( logger.info(
`DataflowService: 관계별 연결 데이터 조회 완료 - ${linkedData.length}` `DataflowService: 관계별 연결 데이터 조회 완료 - ${linkedData.length}`
@ -497,23 +542,22 @@ export class DataflowService {
`DataflowService: 테이블별 연결 데이터 조회 시작 - 테이블: ${tableName}` `DataflowService: 테이블별 연결 데이터 조회 시작 - 테이블: ${tableName}`
); );
const whereCondition: any = { let queryText = `
OR: [{ from_table_name: tableName }, { to_table_name: tableName }], SELECT * FROM data_relationship_bridge
is_active: "Y", WHERE (from_table_name = $1 OR to_table_name = $1) AND is_active = 'Y'
}; `;
const params: any[] = [tableName];
// keyValue 파라미터는 더 이상 사용하지 않음 (key_value 필드 제거됨) // keyValue 파라미터는 더 이상 사용하지 않음 (key_value 필드 제거됨)
// 회사코드 필터링 // 회사코드 필터링
if (companyCode && companyCode !== "*") { if (companyCode && companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $2`;
params.push(companyCode);
} }
const linkedData = await prisma.data_relationship_bridge.findMany({ queryText += ` ORDER BY created_at DESC`;
where: whereCondition, const linkedData = await query(queryText, params);
orderBy: { created_at: "desc" },
// include 제거 - relationship 관계가 스키마에 정의되지 않음
});
logger.info( logger.info(
`DataflowService: 테이블별 연결 데이터 조회 완료 - ${linkedData.length}` `DataflowService: 테이블별 연결 데이터 조회 완료 - ${linkedData.length}`
@ -541,23 +585,25 @@ export class DataflowService {
`DataflowService: 데이터 연결 수정 시작 - Bridge ID: ${bridgeId}` `DataflowService: 데이터 연결 수정 시작 - Bridge ID: ${bridgeId}`
); );
const whereCondition: any = { let queryText = `
bridge_id: bridgeId, UPDATE data_relationship_bridge
is_active: "Y", SET bridge_data = $1, updated_by = $2, updated_at = now()
}; WHERE bridge_id = $3 AND is_active = 'Y'
`;
const params: any[] = [
JSON.stringify(updateData.bridgeData),
updateData.updatedBy,
bridgeId,
];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $4`;
params.push(companyCode);
} }
const updatedBridge = await prisma.data_relationship_bridge.update({ queryText += ` RETURNING *`;
where: whereCondition, const updatedBridge = await queryOne(queryText, params);
data: {
...updateData,
updated_at: new Date(),
},
});
logger.info( logger.info(
`DataflowService: 데이터 연결 수정 완료 - Bridge ID: ${bridgeId}` `DataflowService: 데이터 연결 수정 완료 - Bridge ID: ${bridgeId}`
@ -582,24 +628,20 @@ export class DataflowService {
`DataflowService: 데이터 연결 삭제 시작 - Bridge ID: ${bridgeId}` `DataflowService: 데이터 연결 삭제 시작 - Bridge ID: ${bridgeId}`
); );
const whereCondition: any = { let queryText = `
bridge_id: bridgeId, UPDATE data_relationship_bridge
is_active: "Y", SET is_active = 'N', updated_at = now(), updated_by = $1
}; WHERE bridge_id = $2 AND is_active = 'Y'
`;
const params: any[] = [deletedBy, bridgeId];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $3`;
params.push(companyCode);
} }
await prisma.data_relationship_bridge.update({ await query(queryText, params);
where: whereCondition,
data: {
is_active: "N",
updated_at: new Date(),
updated_by: deletedBy,
},
});
logger.info( logger.info(
`DataflowService: 데이터 연결 삭제 완료 - Bridge ID: ${bridgeId}` `DataflowService: 데이터 연결 삭제 완료 - Bridge ID: ${bridgeId}`
@ -624,29 +666,25 @@ export class DataflowService {
`DataflowService: 관계별 모든 데이터 연결 삭제 시작 - 관계ID: ${relationshipId}` `DataflowService: 관계별 모든 데이터 연결 삭제 시작 - 관계ID: ${relationshipId}`
); );
const whereCondition: any = { let queryText = `
relationship_id: relationshipId, UPDATE data_relationship_bridge
is_active: "Y", SET is_active = 'N', updated_at = now(), updated_by = $1
}; WHERE relationship_id = $2 AND is_active = 'Y'
`;
const params: any[] = [deletedBy, relationshipId];
// 관리자가 아닌 경우 회사코드 제한 // 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") { if (companyCode !== "*") {
whereCondition.company_code = companyCode; queryText += ` AND company_code = $3`;
params.push(companyCode);
} }
const result = await prisma.data_relationship_bridge.updateMany({ const result = await query(queryText, params);
where: whereCondition,
data: {
is_active: "N",
updated_at: new Date(),
updated_by: deletedBy,
},
});
logger.info( logger.info(
`DataflowService: 관계별 모든 데이터 연결 삭제 완료 - ${result.count}` `DataflowService: 관계별 모든 데이터 연결 삭제 완료 - ${result.length}`
); );
return result.count; return result.length;
} catch (error) { } catch (error) {
logger.error("DataflowService: 관계별 모든 데이터 연결 삭제 실패", error); logger.error("DataflowService: 관계별 모든 데이터 연결 삭제 실패", error);
throw error; throw error;
@ -670,47 +708,51 @@ export class DataflowService {
logger.info(`DataflowService: 테이블 데이터 조회 시작 - ${tableName}`); logger.info(`DataflowService: 테이블 데이터 조회 시작 - ${tableName}`);
// 테이블 존재 여부 확인 (정보 스키마 사용) // 테이블 존재 여부 확인 (정보 스키마 사용)
const tableExists = await prisma.$queryRaw` const tableExists = await query(
SELECT table_name `SELECT table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_name = ${tableName.toLowerCase()} WHERE table_name = $1 AND table_schema = 'public'`,
AND table_schema = 'public' [tableName.toLowerCase()]
`; );
if ( if (!tableExists || tableExists.length === 0) {
!tableExists ||
(Array.isArray(tableExists) && tableExists.length === 0)
) {
throw new Error(`테이블 '${tableName}'이 존재하지 않습니다.`); throw new Error(`테이블 '${tableName}'이 존재하지 않습니다.`);
} }
// 전체 데이터 개수 조회 // 전체 데이터 개수 조회 및 데이터 조회
let totalCountQuery = `SELECT COUNT(*) as total FROM "${tableName}"`; let totalCountQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
let dataQuery = `SELECT * FROM "${tableName}"`; let dataQuery = `SELECT * FROM "${tableName}"`;
const queryParams: any[] = [];
// 검색 조건 추가 // 검색 조건 추가 (SQL Injection 방지를 위해 파라미터 바인딩 사용)
if (search && searchColumn) { if (search && searchColumn) {
const whereCondition = `WHERE "${searchColumn}" ILIKE '%${search}%'`; const paramIndex = queryParams.length + 1;
const whereCondition = `WHERE "${searchColumn}" ILIKE $${paramIndex}`;
totalCountQuery += ` ${whereCondition}`; totalCountQuery += ` ${whereCondition}`;
dataQuery += ` ${whereCondition}`; dataQuery += ` ${whereCondition}`;
queryParams.push(`%${search}%`);
} }
// 페이징 처리 // 페이징 처리
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
dataQuery += ` ORDER BY 1 LIMIT ${limit} OFFSET ${offset}`; const limitIndex = queryParams.length + 1;
const offsetIndex = queryParams.length + 2;
dataQuery += ` ORDER BY 1 LIMIT $${limitIndex} OFFSET $${offsetIndex}`;
const dataQueryParams = [...queryParams, limit, offset];
// 실제 쿼리 실행 // 실제 쿼리 실행
const [totalResult, dataResult] = await Promise.all([ const [totalResult, dataResult] = await Promise.all([
prisma.$queryRawUnsafe(totalCountQuery), query(totalCountQuery, queryParams.length > 0 ? queryParams : []),
prisma.$queryRawUnsafe(dataQuery), query(dataQuery, dataQueryParams),
]); ]);
const total = const total =
Array.isArray(totalResult) && totalResult.length > 0 totalResult && totalResult.length > 0
? Number((totalResult[0] as any).total) ? Number((totalResult[0] as any).total)
: 0; : 0;
const data = Array.isArray(dataResult) ? dataResult : []; const data = dataResult || [];
const result = { const result = {
data, data,
@ -752,52 +794,43 @@ export class DataflowService {
`DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}` `DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}`
); );
// diagram_id별로 그룹화하여 조회 // WHERE 조건 구성
const whereCondition = { const params: any[] = [companyCode];
company_code: companyCode, let whereClause = `WHERE company_code = $1 AND is_active = 'Y'`;
is_active: "Y",
...(searchTerm && { if (searchTerm) {
OR: [ whereClause += ` AND (
{ relationship_name ILIKE $2 OR
relationship_name: { from_table_name ILIKE $2 OR
contains: searchTerm, to_table_name ILIKE $2
mode: "insensitive" as any, )`;
}, params.push(`%${searchTerm}%`);
}, }
{
from_table_name: {
contains: searchTerm,
mode: "insensitive" as any,
},
},
{
to_table_name: {
contains: searchTerm,
mode: "insensitive" as any,
},
},
],
}),
};
// diagram_id별로 그룹화된 데이터 조회 // diagram_id별로 그룹화된 데이터 조회
const relationships = await prisma.table_relationships.findMany({ const relationships = await query<{
where: whereCondition, relationship_id: number;
select: { diagram_id: number;
relationship_id: true, relationship_name: string;
diagram_id: true, from_table_name: string;
relationship_name: true, to_table_name: string;
from_table_name: true, connection_type: string;
to_table_name: true, relationship_type: string;
connection_type: true, created_date: Date;
relationship_type: true, created_by: string;
created_date: true, updated_date: Date;
created_by: true, updated_by: string;
updated_date: true, }>(
updated_by: true, `SELECT
}, relationship_id, diagram_id, relationship_name,
orderBy: [{ diagram_id: "asc" }, { created_date: "desc" }], from_table_name, to_table_name, connection_type,
}); relationship_type, created_date, created_by,
updated_date, updated_by
FROM table_relationships
${whereClause}
ORDER BY diagram_id ASC, created_date DESC`,
params
);
// diagram_id별로 그룹화 // diagram_id별로 그룹화
const diagramMap = new Map<number, any>(); const diagramMap = new Map<number, any>();
@ -880,16 +913,14 @@ export class DataflowService {
`DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}` `DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}`
); );
const relationships = await prisma.table_relationships.findMany({ const relationships = await query(
where: { `SELECT * FROM table_relationships
company_code: companyCode, WHERE company_code = $1
relationship_name: diagramName, AND relationship_name = $2
is_active: "Y", AND is_active = 'Y'
}, ORDER BY created_date ASC`,
orderBy: { [companyCode, diagramName]
created_date: "asc", );
},
});
logger.info( logger.info(
`DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계` `DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계`
@ -916,13 +947,27 @@ export class DataflowService {
logger.info(`DataflowService: 관계도 복사 시작 - ${originalDiagramName}`); logger.info(`DataflowService: 관계도 복사 시작 - ${originalDiagramName}`);
// 원본 관계도의 모든 관계 조회 // 원본 관계도의 모든 관계 조회
const originalRelationships = await prisma.table_relationships.findMany({ const originalRelationships = await query<{
where: { relationship_id: number;
company_code: companyCode, diagram_id: number;
relationship_name: originalDiagramName, relationship_name: string;
is_active: "Y", from_table_name: string;
}, from_column_name: string;
}); to_table_name: string;
to_column_name: string;
relationship_type: string;
connection_type: string;
settings: any;
company_code: string;
created_by: string;
updated_by: string;
}>(
`SELECT * FROM table_relationships
WHERE company_code = $1
AND relationship_name = $2
AND is_active = 'Y'`,
[companyCode, originalDiagramName]
);
if (originalRelationships.length === 0) { if (originalRelationships.length === 0) {
throw new Error("복사할 관계도를 찾을 수 없습니다."); throw new Error("복사할 관계도를 찾을 수 없습니다.");
@ -933,13 +978,14 @@ export class DataflowService {
let counter = 1; let counter = 1;
while (true) { while (true) {
const existingDiagram = await prisma.table_relationships.findFirst({ const existingDiagram = await queryOne(
where: { `SELECT relationship_id FROM table_relationships
company_code: companyCode, WHERE company_code = $1
relationship_name: newDiagramName, AND relationship_name = $2
is_active: "Y", AND is_active = 'Y'
}, LIMIT 1`,
}); [companyCode, newDiagramName]
);
if (!existingDiagram) { if (!existingDiagram) {
break; break;
@ -950,43 +996,52 @@ export class DataflowService {
} }
// 새로운 diagram_id 생성 // 새로운 diagram_id 생성
const maxDiagramId = await prisma.table_relationships.findFirst({ const maxDiagramId = await queryOne<{ diagram_id: number }>(
where: { `SELECT diagram_id FROM table_relationships
company_code: companyCode, WHERE company_code = $1
}, ORDER BY diagram_id DESC
orderBy: { LIMIT 1`,
diagram_id: "desc", [companyCode]
}, );
select: {
diagram_id: true,
},
});
const newDiagramId = (maxDiagramId?.diagram_id || 0) + 1; const newDiagramId = (maxDiagramId?.diagram_id || 0) + 1;
// 트랜잭션으로 모든 관계 복사 // 트랜잭션으로 모든 관계 복사
const copiedRelationships = await prisma.$transaction( const copiedRelationships = await transaction(async (client) => {
originalRelationships.map((rel) => const results: any[] = [];
prisma.table_relationships.create({
data: { for (const rel of originalRelationships) {
diagram_id: newDiagramId, const result = await client.query(
relationship_name: newDiagramName, `INSERT INTO table_relationships (
from_table_name: rel.from_table_name, diagram_id, relationship_name, from_table_name, from_column_name,
from_column_name: rel.from_column_name, to_table_name, to_column_name, relationship_type, connection_type,
to_table_name: rel.to_table_name, settings, company_code, is_active, created_by, updated_by, created_at, updated_at
to_column_name: rel.to_column_name, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', $11, $12, now(), now())
relationship_type: rel.relationship_type, RETURNING *`,
connection_type: rel.connection_type, [
settings: rel.settings as any, newDiagramId,
company_code: rel.company_code, newDiagramName,
is_active: "Y", rel.from_table_name,
created_by: rel.created_by, rel.from_column_name,
updated_by: rel.updated_by, rel.to_table_name,
}, rel.to_column_name,
}) rel.relationship_type,
) rel.connection_type,
rel.settings,
rel.company_code,
rel.created_by,
rel.updated_by,
]
); );
if (result.rows && result.rows.length > 0) {
results.push(result.rows[0]);
}
}
return results;
});
logger.info( logger.info(
`DataflowService: 관계도 복사 완료 - ${originalDiagramName}${newDiagramName} (diagram_id: ${newDiagramId}), ${copiedRelationships.length}개 관계 복사` `DataflowService: 관계도 복사 완료 - ${originalDiagramName}${newDiagramName} (diagram_id: ${newDiagramId}), ${copiedRelationships.length}개 관계 복사`
); );
@ -1012,18 +1067,20 @@ export class DataflowService {
logger.info(`DataflowService: 관계도 삭제 시작 - ${diagramName}`); logger.info(`DataflowService: 관계도 삭제 시작 - ${diagramName}`);
// 관계도의 모든 관계 삭제 (하드 삭제) // 관계도의 모든 관계 삭제 (하드 삭제)
const deleteResult = await prisma.table_relationships.deleteMany({ const deleteResult = await query<{ count: number }>(
where: { `DELETE FROM table_relationships
company_code: companyCode, WHERE company_code = $1 AND relationship_name = $2
relationship_name: diagramName, RETURNING relationship_id`,
}, [companyCode, diagramName]
});
logger.info(
`DataflowService: 관계도 삭제 완료 - ${diagramName}, ${deleteResult.count}개 관계 삭제`
); );
return deleteResult.count; const count = deleteResult.length;
logger.info(
`DataflowService: 관계도 삭제 완료 - ${diagramName}, ${count}개 관계 삭제`
);
return count;
} catch (error) { } catch (error) {
logger.error(`DataflowService: 관계도 삭제 실패 - ${diagramName}`, error); logger.error(`DataflowService: 관계도 삭제 실패 - ${diagramName}`, error);
throw error; throw error;
@ -1043,20 +1100,20 @@ export class DataflowService {
); );
// diagram_id로 모든 관계 조회 // diagram_id로 모든 관계 조회
const relationships = await prisma.table_relationships.findMany({ const relationships = await query(
where: { `SELECT * FROM table_relationships
diagram_id: diagramId, WHERE diagram_id = $1
company_code: companyCode, AND company_code = $2
is_active: "Y", AND is_active = 'Y'
}, ORDER BY relationship_id ASC`,
orderBy: [{ relationship_id: "asc" }], [diagramId, companyCode]
}); );
logger.info( logger.info(
`DataflowService: diagram_id로 관계도 관계 조회 완료 - ${relationships.length}개 관계` `DataflowService: diagram_id로 관계도 관계 조회 완료 - ${relationships.length}개 관계`
); );
return relationships.map((rel) => ({ return relationships.map((rel: any) => ({
...rel, ...rel,
settings: rel.settings as any, settings: rel.settings as any,
})); }));
@ -1082,16 +1139,14 @@ export class DataflowService {
); );
// 먼저 해당 relationship_id의 diagram_id를 찾음 // 먼저 해당 relationship_id의 diagram_id를 찾음
const targetRelationship = await prisma.table_relationships.findFirst({ const targetRelationship = await queryOne<{ diagram_id: number }>(
where: { `SELECT diagram_id FROM table_relationships
relationship_id: relationshipId, WHERE relationship_id = $1
company_code: companyCode, AND company_code = $2
is_active: "Y", AND is_active = 'Y'
}, LIMIT 1`,
select: { [relationshipId, companyCode]
diagram_id: true, );
},
});
if (!targetRelationship) { if (!targetRelationship) {
throw new Error("해당 관계 ID를 찾을 수 없습니다."); throw new Error("해당 관계 ID를 찾을 수 없습니다.");

View File

@ -3,7 +3,7 @@
* PostgreSQL * PostgreSQL
*/ */
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import { import {
CreateColumnDefinition, CreateColumnDefinition,
DDLExecutionResult, DDLExecutionResult,
@ -15,8 +15,6 @@ import { DDLAuditLogger } from "./ddlAuditLogger";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache"; import { cache, CacheKeys } from "../utils/cache";
const prisma = new PrismaClient();
export class DDLExecutionService { export class DDLExecutionService {
/** /**
* *
@ -98,15 +96,15 @@ export class DDLExecutionService {
const ddlQuery = this.generateCreateTableQuery(tableName, columns); const ddlQuery = this.generateCreateTableQuery(tableName, columns);
// 5. 트랜잭션으로 안전하게 실행 // 5. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 5-1. 테이블 생성 // 5-1. 테이블 생성
await tx.$executeRawUnsafe(ddlQuery); await client.query(ddlQuery);
// 5-2. 테이블 메타데이터 저장 // 5-2. 테이블 메타데이터 저장
await this.saveTableMetadata(tx, tableName, description); await this.saveTableMetadata(client, tableName, description);
// 5-3. 컬럼 메타데이터 저장 // 5-3. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, columns); await this.saveColumnMetadata(client, tableName, columns);
}); });
// 6. 성공 로그 기록 // 6. 성공 로그 기록
@ -269,12 +267,12 @@ export class DDLExecutionService {
const ddlQuery = this.generateAddColumnQuery(tableName, column); const ddlQuery = this.generateAddColumnQuery(tableName, column);
// 6. 트랜잭션으로 안전하게 실행 // 6. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 6-1. 컬럼 추가 // 6-1. 컬럼 추가
await tx.$executeRawUnsafe(ddlQuery); await client.query(ddlQuery);
// 6-2. 컬럼 메타데이터 저장 // 6-2. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, [column]); await this.saveColumnMetadata(client, tableName, [column]);
}); });
// 7. 성공 로그 기록 // 7. 성공 로그 기록
@ -424,51 +422,42 @@ CREATE TABLE "${tableName}" (${baseColumns},
* *
*/ */
private async saveTableMetadata( private async saveTableMetadata(
tx: any, client: any,
tableName: string, tableName: string,
description?: string description?: string
): Promise<void> { ): Promise<void> {
await tx.table_labels.upsert({ await client.query(
where: { table_name: tableName }, `
update: { INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
table_label: tableName, VALUES ($1, $2, $3, now(), now())
description: description || `사용자 생성 테이블: ${tableName}`, ON CONFLICT (table_name)
updated_date: new Date(), DO UPDATE SET
}, table_label = $2,
create: { description = $3,
table_name: tableName, updated_date = now()
table_label: tableName, `,
description: description || `사용자 생성 테이블: ${tableName}`, [tableName, tableName, description || `사용자 생성 테이블: ${tableName}`]
created_date: new Date(), );
updated_date: new Date(),
},
});
} }
/** /**
* *
*/ */
private async saveColumnMetadata( private async saveColumnMetadata(
tx: any, client: any,
tableName: string, tableName: string,
columns: CreateColumnDefinition[] columns: CreateColumnDefinition[]
): Promise<void> { ): Promise<void> {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await tx.table_labels.upsert({ await client.query(
where: { `
table_name: tableName, INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
}, VALUES ($1, $2, $3, now(), now())
update: { ON CONFLICT (table_name)
updated_date: new Date(), DO UPDATE SET updated_date = now()
}, `,
create: { [tableName, tableName, `자동 생성된 테이블 메타데이터: ${tableName}`]
table_name: tableName, );
table_label: tableName,
description: `자동 생성된 테이블 메타데이터: ${tableName}`,
created_date: new Date(),
updated_date: new Date(),
},
});
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼) // 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
const defaultColumns = [ const defaultColumns = [
@ -516,20 +505,23 @@ CREATE TABLE "${tableName}" (${baseColumns},
// 기본 컬럼들을 table_type_columns에 등록 // 기본 컬럼들을 table_type_columns에 등록
for (const defaultCol of defaultColumns) { for (const defaultCol of defaultColumns) {
await tx.$executeRaw` await client.query(
`
INSERT INTO table_type_columns ( INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date is_nullable, display_order, created_date, updated_date
) VALUES ( ) VALUES (
${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}', $1, $2, $3, '{}',
'Y', ${defaultCol.order}, now(), now() 'Y', $4, now(), now()
) )
ON CONFLICT (table_name, column_name) ON CONFLICT (table_name, column_name)
DO UPDATE SET DO UPDATE SET
input_type = ${defaultCol.inputType}, input_type = $3,
display_order = ${defaultCol.order}, display_order = $4,
updated_date = now(); updated_date = now()
`; `,
[tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
);
} }
// 사용자 정의 컬럼들을 table_type_columns에 등록 // 사용자 정의 컬럼들을 table_type_columns에 등록
@ -538,89 +530,98 @@ CREATE TABLE "${tableName}" (${baseColumns},
const inputType = this.convertWebTypeToInputType( const inputType = this.convertWebTypeToInputType(
column.webType || "text" column.webType || "text"
); );
const detailSettings = JSON.stringify(column.detailSettings || {});
await tx.$executeRaw` await client.query(
`
INSERT INTO table_type_columns ( INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date is_nullable, display_order, created_date, updated_date
) VALUES ( ) VALUES (
${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})}, $1, $2, $3, $4,
'Y', ${i}, now(), now() 'Y', $5, now(), now()
) )
ON CONFLICT (table_name, column_name) ON CONFLICT (table_name, column_name)
DO UPDATE SET DO UPDATE SET
input_type = ${inputType}, input_type = $3,
detail_settings = ${JSON.stringify(column.detailSettings || {})}, detail_settings = $4,
display_order = ${i}, display_order = $5,
updated_date = now(); updated_date = now()
`; `,
[tableName, column.name, inputType, detailSettings, i]
);
} }
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성) // 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 1. 기본 컬럼들을 column_labels에 등록 // 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) { for (const defaultCol of defaultColumns) {
await tx.column_labels.upsert({ await client.query(
where: { `
table_name_column_name: { INSERT INTO column_labels (
table_name: tableName, table_name, column_name, column_label, input_type, detail_settings,
column_name: defaultCol.name, description, display_order, is_visible, created_date, updated_date
}, ) VALUES (
}, $1, $2, $3, $4, $5, $6, $7, $8, now(), now()
update: { )
column_label: defaultCol.label, ON CONFLICT (table_name, column_name)
input_type: defaultCol.inputType, DO UPDATE SET
detail_settings: JSON.stringify({}), column_label = $3,
description: defaultCol.description, input_type = $4,
display_order: defaultCol.order, detail_settings = $5,
is_visible: defaultCol.isVisible, description = $6,
updated_date: new Date(), display_order = $7,
}, is_visible = $8,
create: { updated_date = now()
table_name: tableName, `,
column_name: defaultCol.name, [
column_label: defaultCol.label, tableName,
input_type: defaultCol.inputType, defaultCol.name,
detail_settings: JSON.stringify({}), defaultCol.label,
description: defaultCol.description, defaultCol.inputType,
display_order: defaultCol.order, JSON.stringify({}),
is_visible: defaultCol.isVisible, defaultCol.description,
created_date: new Date(), defaultCol.order,
updated_date: new Date(), defaultCol.isVisible,
}, ]
}); );
} }
// 2. 사용자 정의 컬럼들을 column_labels에 등록 // 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) { for (const column of columns) {
await tx.column_labels.upsert({ const inputType = this.convertWebTypeToInputType(
where: { column.webType || "text"
table_name_column_name: { );
table_name: tableName, const detailSettings = JSON.stringify(column.detailSettings || {});
column_name: column.name,
}, await client.query(
}, `
update: { INSERT INTO column_labels (
column_label: column.label || column.name, table_name, column_name, column_label, input_type, detail_settings,
input_type: this.convertWebTypeToInputType(column.webType || "text"), description, display_order, is_visible, created_date, updated_date
detail_settings: JSON.stringify(column.detailSettings || {}), ) VALUES (
description: column.description, $1, $2, $3, $4, $5, $6, $7, $8, now(), now()
display_order: column.order || 0, )
is_visible: true, ON CONFLICT (table_name, column_name)
updated_date: new Date(), DO UPDATE SET
}, column_label = $3,
create: { input_type = $4,
table_name: tableName, detail_settings = $5,
column_name: column.name, description = $6,
column_label: column.label || column.name, display_order = $7,
input_type: this.convertWebTypeToInputType(column.webType || "text"), is_visible = $8,
detail_settings: JSON.stringify(column.detailSettings || {}), updated_date = now()
description: column.description, `,
display_order: column.order || 0, [
is_visible: true, tableName,
created_date: new Date(), column.name,
updated_date: new Date(), column.label || column.name,
}, inputType,
}); detailSettings,
column.description,
column.order || 0,
true,
]
);
} }
} }
@ -679,18 +680,18 @@ CREATE TABLE "${tableName}" (${baseColumns},
*/ */
private async checkTableExists(tableName: string): Promise<boolean> { private async checkTableExists(tableName: string): Promise<boolean> {
try { try {
const result = await prisma.$queryRawUnsafe( const result = await queryOne<{ exists: boolean }>(
` `
SELECT EXISTS ( SELECT EXISTS (
SELECT FROM information_schema.tables SELECT FROM information_schema.tables
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = $1 AND table_name = $1
); )
`, `,
tableName [tableName]
); );
return (result as any)[0]?.exists || false; return result?.exists || false;
} catch (error) { } catch (error) {
logger.error("테이블 존재 확인 오류:", error); logger.error("테이블 존재 확인 오류:", error);
return false; return false;
@ -705,20 +706,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
columnName: string columnName: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
const result = await prisma.$queryRawUnsafe( const result = await queryOne<{ exists: boolean }>(
` `
SELECT EXISTS ( SELECT EXISTS (
SELECT FROM information_schema.columns SELECT FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = $1 AND table_name = $1
AND column_name = $2 AND column_name = $2
); )
`, `,
tableName, [tableName, columnName]
columnName
); );
return (result as any)[0]?.exists || false; return result?.exists || false;
} catch (error) { } catch (error) {
logger.error("컬럼 존재 확인 오류:", error); logger.error("컬럼 존재 확인 오류:", error);
return false; return false;
@ -734,15 +734,16 @@ CREATE TABLE "${tableName}" (${baseColumns},
} | null> { } | null> {
try { try {
// 테이블 정보 조회 // 테이블 정보 조회
const tableInfo = await prisma.table_labels.findUnique({ const tableInfo = await queryOne(
where: { table_name: tableName }, `SELECT * FROM table_labels WHERE table_name = $1`,
}); [tableName]
);
// 컬럼 정보 조회 // 컬럼 정보 조회
const columns = await prisma.column_labels.findMany({ const columns = await query(
where: { table_name: tableName }, `SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
orderBy: { display_order: "asc" }, [tableName]
}); );
if (!tableInfo) { if (!tableInfo) {
return null; return null;

View File

@ -1,5 +1,4 @@
import prisma from "../config/database"; import { query, queryOne } from "../database/db";
import { Prisma } from "@prisma/client";
import { EventTriggerService } from "./eventTriggerService"; import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService"; import { DataflowControlService } from "./dataflowControlService";
@ -44,7 +43,7 @@ export interface TableColumn {
dataType: string; dataType: string;
nullable: boolean; nullable: boolean;
primaryKey: boolean; primaryKey: boolean;
maxLength?: number; maxLength?: number | null;
defaultValue?: any; defaultValue?: any;
} }
@ -140,14 +139,13 @@ export class DynamicFormService {
tableName: string tableName: string
): Promise<Array<{ column_name: string; data_type: string }>> { ): Promise<Array<{ column_name: string; data_type: string }>> {
try { try {
const result = await prisma.$queryRaw< const result = await query<{ column_name: string; data_type: string }>(
Array<{ column_name: string; data_type: string }> `SELECT column_name, data_type
>`
SELECT column_name, data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = ${tableName} WHERE table_name = $1
AND table_schema = 'public' AND table_schema = 'public'`,
`; [tableName]
);
return result; return result;
} catch (error) { } catch (error) {
@ -161,12 +159,13 @@ export class DynamicFormService {
*/ */
private async getTableColumnNames(tableName: string): Promise<string[]> { private async getTableColumnNames(tableName: string): Promise<string[]> {
try { try {
const result = (await prisma.$queryRawUnsafe(` const result = await query<{ column_name: string }>(
SELECT column_name `SELECT column_name
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = '${tableName}' WHERE table_name = $1
AND table_schema = 'public' AND table_schema = 'public'`,
`)) as any[]; [tableName]
);
return result.map((row) => row.column_name); return result.map((row) => row.column_name);
} catch (error) { } catch (error) {
@ -180,15 +179,16 @@ export class DynamicFormService {
*/ */
async getTablePrimaryKeys(tableName: string): Promise<string[]> { async getTablePrimaryKeys(tableName: string): Promise<string[]> {
try { try {
const result = (await prisma.$queryRawUnsafe(` const result = await query<{ column_name: string }>(
SELECT kcu.column_name `SELECT kcu.column_name
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = '${tableName}' WHERE tc.table_name = $1
AND tc.constraint_type = 'PRIMARY KEY' AND tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = 'public' AND tc.table_schema = 'public'`,
`)) as any[]; [tableName]
);
return result.map((row) => row.column_name); return result.map((row) => row.column_name);
} catch (error) { } catch (error) {
@ -381,7 +381,7 @@ export class DynamicFormService {
console.log("📝 실행할 UPSERT SQL:", upsertQuery); console.log("📝 실행할 UPSERT SQL:", upsertQuery);
console.log("📊 SQL 파라미터:", values); console.log("📊 SQL 파라미터:", values);
const result = await prisma.$queryRawUnsafe(upsertQuery, ...values); const result = await query<any>(upsertQuery, values);
console.log("✅ 서비스: 실제 테이블 저장 성공:", result); console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
@ -528,7 +528,7 @@ export class DynamicFormService {
console.log("📝 실행할 부분 UPDATE SQL:", updateQuery); console.log("📝 실행할 부분 UPDATE SQL:", updateQuery);
console.log("📊 SQL 파라미터:", values); console.log("📊 SQL 파라미터:", values);
const result = await prisma.$queryRawUnsafe(updateQuery, ...values); const result = await query<any>(updateQuery, values);
console.log("✅ 서비스: 부분 업데이트 성공:", result); console.log("✅ 서비스: 부분 업데이트 성공:", result);
@ -643,13 +643,14 @@ export class DynamicFormService {
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
// 기본키 데이터 타입 조회하여 적절한 캐스팅 적용 // 기본키 데이터 타입 조회하여 적절한 캐스팅 적용
const primaryKeyInfo = (await prisma.$queryRawUnsafe(` const primaryKeyInfo = await query<{ data_type: string }>(
SELECT data_type `SELECT data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = '${tableName}' WHERE table_name = $1
AND column_name = '${primaryKeyColumn}' AND column_name = $2
AND table_schema = 'public' AND table_schema = 'public'`,
`)) as any[]; [tableName, primaryKeyColumn]
);
let typeCastSuffix = ""; let typeCastSuffix = "";
if (primaryKeyInfo.length > 0) { if (primaryKeyInfo.length > 0) {
@ -678,7 +679,7 @@ export class DynamicFormService {
console.log("📝 실행할 UPDATE SQL:", updateQuery); console.log("📝 실행할 UPDATE SQL:", updateQuery);
console.log("📊 SQL 파라미터:", values); console.log("📊 SQL 파라미터:", values);
const result = await prisma.$queryRawUnsafe(updateQuery, ...values); const result = await query<any>(updateQuery, values);
console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result); console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result);
@ -760,20 +761,16 @@ export class DynamicFormService {
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName); console.log("🔍 테이블명:", tableName);
const primaryKeyResult = await prisma.$queryRawUnsafe( const primaryKeyResult = await query<{
primaryKeyQuery, column_name: string;
tableName data_type: string;
); }>(primaryKeyQuery, [tableName]);
if ( if (!primaryKeyResult || primaryKeyResult.length === 0) {
!primaryKeyResult ||
!Array.isArray(primaryKeyResult) ||
primaryKeyResult.length === 0
) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
} }
const primaryKeyInfo = primaryKeyResult[0] as any; const primaryKeyInfo = primaryKeyResult[0];
const primaryKeyColumn = primaryKeyInfo.column_name; const primaryKeyColumn = primaryKeyInfo.column_name;
const primaryKeyDataType = primaryKeyInfo.data_type; const primaryKeyDataType = primaryKeyInfo.data_type;
console.log("🔑 발견된 기본키:", { console.log("🔑 발견된 기본키:", {
@ -810,7 +807,7 @@ export class DynamicFormService {
console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📝 실행할 DELETE SQL:", deleteQuery);
console.log("📊 SQL 파라미터:", [id]); console.log("📊 SQL 파라미터:", [id]);
const result = await prisma.$queryRawUnsafe(deleteQuery, id); const result = await query<any>(deleteQuery, [id]);
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
@ -864,9 +861,21 @@ export class DynamicFormService {
try { try {
console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id }); console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id });
const result = await prisma.dynamic_form_data.findUnique({ const result = await queryOne<{
where: { id }, id: number;
}); screen_id: number;
table_name: string;
form_data: any;
created_at: Date | null;
updated_at: Date | null;
created_by: string;
updated_by: string;
}>(
`SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
FROM dynamic_form_data
WHERE id = $1`,
[id]
);
if (!result) { if (!result) {
console.log("❌ 서비스: 폼 데이터를 찾을 수 없음"); console.log("❌ 서비스: 폼 데이터를 찾을 수 없음");
@ -914,50 +923,62 @@ export class DynamicFormService {
sortBy = "created_at", sortBy = "created_at",
sortOrder = "desc", sortOrder = "desc",
} = params; } = params;
const skip = (page - 1) * size; const offset = (page - 1) * size;
// 검색 조건 구성 // 정렬 컬럼 검증 (SQL Injection 방지)
const where: Prisma.dynamic_form_dataWhereInput = { const allowedSortColumns = ["created_at", "updated_at", "id"];
screen_id: screenId, const validSortBy = allowedSortColumns.includes(sortBy)
}; ? sortBy
: "created_at";
const validSortOrder = sortOrder === "asc" ? "ASC" : "DESC";
// 검색 조건 및 파라미터 구성
const queryParams: any[] = [screenId];
let searchCondition = "";
// 검색어가 있는 경우 form_data 필드에서 검색
if (search) { if (search) {
where.OR = [ searchCondition = ` AND (
{ form_data::text ILIKE $2
form_data: { OR table_name ILIKE $2
path: [], )`;
string_contains: search, queryParams.push(`%${search}%`);
},
},
{
table_name: {
contains: search,
mode: "insensitive",
},
},
];
} }
// 정렬 조건 구성 // 데이터 조회 쿼리
const orderBy: Prisma.dynamic_form_dataOrderByWithRelationInput = {}; const dataQuery = `
if (sortBy === "created_at" || sortBy === "updated_at") { SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
orderBy[sortBy] = sortOrder; FROM dynamic_form_data
} else { WHERE screen_id = $1
orderBy.created_at = "desc"; // 기본값 ${searchCondition}
} ORDER BY ${validSortBy} ${validSortOrder}
LIMIT ${size} OFFSET ${offset}
`;
// 데이터 조회 // 전체 개수 조회 쿼리
const [results, totalCount] = await Promise.all([ const countQuery = `
prisma.dynamic_form_data.findMany({ SELECT COUNT(*) as total
where, FROM dynamic_form_data
orderBy, WHERE screen_id = $1
skip, ${searchCondition}
take: size, `;
}),
prisma.dynamic_form_data.count({ where }), // 병렬 실행
const [results, countResult] = await Promise.all([
query<{
id: number;
screen_id: number;
table_name: string;
form_data: any;
created_at: Date | null;
updated_at: Date | null;
created_by: string;
updated_by: string;
}>(dataQuery, queryParams),
query<{ total: string }>(countQuery, queryParams),
]); ]);
const totalCount = parseInt(countResult[0]?.total || "0");
const formDataResults: FormDataResult[] = results.map((result) => ({ const formDataResults: FormDataResult[] = results.map((result) => ({
id: result.id, id: result.id,
screenId: result.screen_id, screenId: result.screen_id,
@ -1036,22 +1057,29 @@ export class DynamicFormService {
console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName }); console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName });
// PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회 // PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회
const columns = await prisma.$queryRaw<any[]>` const columns = await query<{
SELECT column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
character_maximum_length: number | null;
}>(
`SELECT
column_name, column_name,
data_type, data_type,
is_nullable, is_nullable,
column_default, column_default,
character_maximum_length character_maximum_length
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = ${tableName} WHERE table_name = $1
AND table_schema = 'public' AND table_schema = 'public'
ORDER BY ordinal_position ORDER BY ordinal_position`,
`; [tableName]
);
// Primary key 정보 조회 // Primary key 정보 조회
const primaryKeys = await prisma.$queryRaw<any[]>` const primaryKeys = await query<{ column_name: string }>(
SELECT `SELECT
kcu.column_name kcu.column_name
FROM FROM
information_schema.table_constraints tc information_schema.table_constraints tc
@ -1059,9 +1087,10 @@ export class DynamicFormService {
ON tc.constraint_name = kcu.constraint_name ON tc.constraint_name = kcu.constraint_name
WHERE WHERE
tc.constraint_type = 'PRIMARY KEY' tc.constraint_type = 'PRIMARY KEY'
AND tc.table_name = ${tableName} AND tc.table_name = $1
AND tc.table_schema = 'public' AND tc.table_schema = 'public'`,
`; [tableName]
);
const primaryKeyColumns = new Set( const primaryKeyColumns = new Set(
primaryKeys.map((pk) => pk.column_name) primaryKeys.map((pk) => pk.column_name)
@ -1098,12 +1127,16 @@ export class DynamicFormService {
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
// 화면의 저장 버튼에서 제어관리 설정 조회 // 화면의 저장 버튼에서 제어관리 설정 조회
const screenLayouts = await prisma.screen_layouts.findMany({ const screenLayouts = await query<{
where: { component_id: string;
screen_id: screenId, properties: any;
component_type: "component", }>(
}, `SELECT component_id, properties
}); FROM screen_layouts
WHERE screen_id = $1
AND component_type = $2`,
[screenId, "component"]
);
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length); console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);

View File

@ -344,13 +344,14 @@ export class ExternalCallConfigService {
} }
// 3. 외부 API 호출 // 3. 외부 API 호출
const callResult = await this.executeExternalCall(config, processedData, contextData); const callResult = await this.executeExternalCall(
config,
processedData,
contextData
);
// 4. Inbound 데이터 매핑 처리 (있는 경우) // 4. Inbound 데이터 매핑 처리 (있는 경우)
if ( if (callResult.success && configData?.dataMappingConfig?.inboundMapping) {
callResult.success &&
configData?.dataMappingConfig?.inboundMapping
) {
logger.info("Inbound 데이터 매핑 처리 중..."); logger.info("Inbound 데이터 매핑 처리 중...");
await this.processInboundMapping( await this.processInboundMapping(
configData.dataMappingConfig.inboundMapping, configData.dataMappingConfig.inboundMapping,
@ -374,7 +375,8 @@ export class ExternalCallConfigService {
const executionTime = performance.now() - startTime; const executionTime = performance.now() - startTime;
logger.error("외부호출 실행 실패:", error); logger.error("외부호출 실행 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; const errorMessage =
error instanceof Error ? error.message : "알 수 없는 오류";
return { return {
success: false, success: false,
@ -388,14 +390,16 @@ export class ExternalCallConfigService {
/** /**
* 🔥 ( ) * 🔥 ( )
*/ */
async getConfigsForButtonControl(companyCode: string): Promise<Array<{ async getConfigsForButtonControl(companyCode: string): Promise<
Array<{
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
apiUrl: string; apiUrl: string;
method: string; method: string;
hasDataMapping: boolean; hasDataMapping: boolean;
}>> { }>
> {
try { try {
const configs = await prisma.external_call_configs.findMany({ const configs = await prisma.external_call_configs.findMany({
where: { where: {
@ -421,7 +425,7 @@ export class ExternalCallConfigService {
description: config.description || undefined, description: config.description || undefined,
apiUrl: configData?.restApiSettings?.apiUrl || "", apiUrl: configData?.restApiSettings?.apiUrl || "",
method: configData?.restApiSettings?.httpMethod || "GET", method: configData?.restApiSettings?.httpMethod || "GET",
hasDataMapping: !!(configData?.dataMappingConfig), hasDataMapping: !!configData?.dataMappingConfig,
}; };
}); });
} catch (error) { } catch (error) {
@ -445,7 +449,12 @@ export class ExternalCallConfigService {
throw new Error("REST API 설정이 없습니다."); throw new Error("REST API 설정이 없습니다.");
} }
const { apiUrl, httpMethod, headers = {}, timeout = 30000 } = restApiSettings; const {
apiUrl,
httpMethod,
headers = {},
timeout = 30000,
} = restApiSettings;
// 요청 헤더 준비 // 요청 헤더 준비
const requestHeaders = { const requestHeaders = {
@ -456,7 +465,9 @@ export class ExternalCallConfigService {
// 인증 처리 // 인증 처리
if (restApiSettings.authentication?.type === "basic") { if (restApiSettings.authentication?.type === "basic") {
const { username, password } = restApiSettings.authentication; const { username, password } = restApiSettings.authentication;
const credentials = Buffer.from(`${username}:${password}`).toString("base64"); const credentials = Buffer.from(`${username}:${password}`).toString(
"base64"
);
requestHeaders["Authorization"] = `Basic ${credentials}`; requestHeaders["Authorization"] = `Basic ${credentials}`;
} else if (restApiSettings.authentication?.type === "bearer") { } else if (restApiSettings.authentication?.type === "bearer") {
const { token } = restApiSettings.authentication; const { token } = restApiSettings.authentication;
@ -495,7 +506,8 @@ export class ExternalCallConfigService {
}; };
} catch (error) { } catch (error) {
logger.error("외부 API 호출 실패:", error); logger.error("외부 API 호출 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; const errorMessage =
error instanceof Error ? error.message : "알 수 없는 오류";
return { return {
success: false, success: false,
error: errorMessage, error: errorMessage,
@ -559,7 +571,6 @@ export class ExternalCallConfigService {
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요 // 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트 // 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
} catch (error) { } catch (error) {
logger.error("Inbound 데이터 매핑 처리 실패:", error); logger.error("Inbound 데이터 매핑 처리 실패:", error);
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음 // Inbound 매핑 실패는 전체 플로우를 중단하지 않음

View File

@ -1,7 +1,7 @@
// 외부 DB 연결 서비스 // 외부 DB 연결 서비스
// 작성일: 2024-12-17 // 작성일: 2024-12-17
import prisma from "../config/database"; import { query, queryOne } from "../database/db";
import { import {
ExternalDbConnection, ExternalDbConnection,
ExternalDbConnectionFilter, ExternalDbConnectionFilter,
@ -20,43 +20,47 @@ export class ExternalDbConnectionService {
filter: ExternalDbConnectionFilter filter: ExternalDbConnectionFilter
): Promise<ApiResponse<ExternalDbConnection[]>> { ): Promise<ApiResponse<ExternalDbConnection[]>> {
try { try {
const where: any = {}; // WHERE 조건 동적 생성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 필터 조건 적용 // 필터 조건 적용
if (filter.db_type) { if (filter.db_type) {
where.db_type = filter.db_type; whereConditions.push(`db_type = $${paramIndex++}`);
params.push(filter.db_type);
} }
if (filter.is_active) { if (filter.is_active) {
where.is_active = filter.is_active; whereConditions.push(`is_active = $${paramIndex++}`);
params.push(filter.is_active);
} }
if (filter.company_code) { if (filter.company_code) {
where.company_code = filter.company_code; whereConditions.push(`company_code = $${paramIndex++}`);
params.push(filter.company_code);
} }
// 검색 조건 적용 (연결명 또는 설명에서 검색) // 검색 조건 적용 (연결명 또는 설명에서 검색)
if (filter.search && filter.search.trim()) { if (filter.search && filter.search.trim()) {
where.OR = [ whereConditions.push(
{ `(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
connection_name: { );
contains: filter.search.trim(), params.push(`%${filter.search.trim()}%`);
mode: "insensitive", paramIndex++;
},
},
{
description: {
contains: filter.search.trim(),
mode: "insensitive",
},
},
];
} }
const connections = await prisma.external_db_connections.findMany({ const whereClause =
where, whereConditions.length > 0
orderBy: [{ is_active: "desc" }, { connection_name: "asc" }], ? `WHERE ${whereConditions.join(" AND ")}`
}); : "";
const connections = await query<any>(
`SELECT * FROM external_db_connections
${whereClause}
ORDER BY is_active DESC, connection_name ASC`,
params
);
// 비밀번호는 반환하지 않음 (보안) // 비밀번호는 반환하지 않음 (보안)
const safeConnections = connections.map((conn) => ({ const safeConnections = connections.map((conn) => ({
@ -93,18 +97,17 @@ export class ExternalDbConnectionService {
if (!connectionsResult.success || !connectionsResult.data) { if (!connectionsResult.success || !connectionsResult.data) {
return { return {
success: false, success: false,
message: "연결 목록 조회에 실패했습니다." message: "연결 목록 조회에 실패했습니다.",
}; };
} }
// DB 타입 카테고리 정보 조회 // DB 타입 카테고리 정보 조회
const categories = await prisma.db_type_categories.findMany({ const categories = await query<any>(
where: { is_active: true }, `SELECT * FROM db_type_categories
orderBy: [ WHERE is_active = true
{ sort_order: 'asc' }, ORDER BY sort_order ASC, display_name ASC`,
{ display_name: 'asc' } []
] );
});
// DB 타입별로 그룹화 // DB 타입별로 그룹화
const groupedConnections: Record<string, any> = {}; const groupedConnections: Record<string, any> = {};
@ -117,36 +120,36 @@ export class ExternalDbConnectionService {
display_name: category.display_name, display_name: category.display_name,
icon: category.icon, icon: category.icon,
color: category.color, color: category.color,
sort_order: category.sort_order sort_order: category.sort_order,
}, },
connections: [] connections: [],
}; };
}); });
// 연결을 해당 타입 그룹에 배치 // 연결을 해당 타입 그룹에 배치
connectionsResult.data.forEach(connection => { connectionsResult.data.forEach((connection) => {
if (groupedConnections[connection.db_type]) { if (groupedConnections[connection.db_type]) {
groupedConnections[connection.db_type].connections.push(connection); groupedConnections[connection.db_type].connections.push(connection);
} else { } else {
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가 // 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
if (!groupedConnections['other']) { if (!groupedConnections["other"]) {
groupedConnections['other'] = { groupedConnections["other"] = {
category: { category: {
type_code: 'other', type_code: "other",
display_name: '기타', display_name: "기타",
icon: 'database', icon: "database",
color: '#6B7280', color: "#6B7280",
sort_order: 999 sort_order: 999,
}, },
connections: [] connections: [],
}; };
} }
groupedConnections['other'].connections.push(connection); groupedConnections["other"].connections.push(connection);
} }
}); });
// 연결이 없는 빈 그룹 제거 // 연결이 없는 빈 그룹 제거
Object.keys(groupedConnections).forEach(key => { Object.keys(groupedConnections).forEach((key) => {
if (groupedConnections[key].connections.length === 0) { if (groupedConnections[key].connections.length === 0) {
delete groupedConnections[key]; delete groupedConnections[key];
} }
@ -155,14 +158,14 @@ export class ExternalDbConnectionService {
return { return {
success: true, success: true,
data: groupedConnections, data: groupedConnections,
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.` message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
}; };
} catch (error) { } catch (error) {
console.error("그룹화된 연결 목록 조회 실패:", error); console.error("그룹화된 연결 목록 조회 실패:", error);
return { return {
success: false, success: false,
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.", message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -174,9 +177,10 @@ export class ExternalDbConnectionService {
id: number id: number
): Promise<ApiResponse<ExternalDbConnection>> { ): Promise<ApiResponse<ExternalDbConnection>> {
try { try {
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<any>(
where: { id }, `SELECT * FROM external_db_connections WHERE id = $1`,
}); [id]
);
if (!connection) { if (!connection) {
return { return {
@ -214,9 +218,10 @@ export class ExternalDbConnectionService {
id: number id: number
): Promise<ApiResponse<ExternalDbConnection>> { ): Promise<ApiResponse<ExternalDbConnection>> {
try { try {
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<any>(
where: { id }, `SELECT * FROM external_db_connections WHERE id = $1`,
}); [id]
);
if (!connection) { if (!connection) {
return { return {
@ -257,13 +262,11 @@ export class ExternalDbConnectionService {
this.validateConnectionData(data); this.validateConnectionData(data);
// 연결명 중복 확인 // 연결명 중복 확인
const existingConnection = await prisma.external_db_connections.findFirst( const existingConnection = await queryOne(
{ `SELECT id FROM external_db_connections
where: { WHERE connection_name = $1 AND company_code = $2
connection_name: data.connection_name, LIMIT 1`,
company_code: data.company_code, [data.connection_name, data.company_code]
},
}
); );
if (existingConnection) { if (existingConnection) {
@ -276,30 +279,35 @@ export class ExternalDbConnectionService {
// 비밀번호 암호화 // 비밀번호 암호화
const encryptedPassword = PasswordEncryption.encrypt(data.password); const encryptedPassword = PasswordEncryption.encrypt(data.password);
const newConnection = await prisma.external_db_connections.create({ const newConnection = await queryOne<any>(
data: { `INSERT INTO external_db_connections (
connection_name: data.connection_name, connection_name, description, db_type, host, port, database_name,
description: data.description, username, password, connection_timeout, query_timeout, max_connections,
db_type: data.db_type, ssl_enabled, ssl_cert_path, connection_options, company_code, is_active,
host: data.host, created_by, updated_by, created_date, updated_date
port: data.port, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW())
database_name: data.database_name, RETURNING *`,
username: data.username, [
password: encryptedPassword, data.connection_name,
connection_timeout: data.connection_timeout, data.description,
query_timeout: data.query_timeout, data.db_type,
max_connections: data.max_connections, data.host,
ssl_enabled: data.ssl_enabled, data.port,
ssl_cert_path: data.ssl_cert_path, data.database_name,
connection_options: data.connection_options as any, data.username,
company_code: data.company_code, encryptedPassword,
is_active: data.is_active, data.connection_timeout,
created_by: data.created_by, data.query_timeout,
updated_by: data.updated_by, data.max_connections,
created_date: new Date(), data.ssl_enabled,
updated_date: new Date(), data.ssl_cert_path,
}, JSON.stringify(data.connection_options),
}); data.company_code,
data.is_active,
data.created_by,
data.updated_by,
]
);
// 비밀번호는 반환하지 않음 // 비밀번호는 반환하지 않음
const safeConnection = { const safeConnection = {
@ -332,10 +340,10 @@ export class ExternalDbConnectionService {
): Promise<ApiResponse<ExternalDbConnection>> { ): Promise<ApiResponse<ExternalDbConnection>> {
try { try {
// 기존 연결 확인 // 기존 연결 확인
const existingConnection = const existingConnection = await queryOne<any>(
await prisma.external_db_connections.findUnique({ `SELECT * FROM external_db_connections WHERE id = $1`,
where: { id }, [id]
}); );
if (!existingConnection) { if (!existingConnection) {
return { return {
@ -346,15 +354,18 @@ export class ExternalDbConnectionService {
// 연결명 중복 확인 (자신 제외) // 연결명 중복 확인 (자신 제외)
if (data.connection_name) { if (data.connection_name) {
const duplicateConnection = const duplicateConnection = await queryOne(
await prisma.external_db_connections.findFirst({ `SELECT id FROM external_db_connections
where: { WHERE connection_name = $1
connection_name: data.connection_name, AND company_code = $2
company_code: AND id != $3
LIMIT 1`,
[
data.connection_name,
data.company_code || existingConnection.company_code, data.company_code || existingConnection.company_code,
id: { not: id }, id,
}, ]
}); );
if (duplicateConnection) { if (duplicateConnection) {
return { return {
@ -406,23 +417,59 @@ export class ExternalDbConnectionService {
} }
// 업데이트 데이터 준비 // 업데이트 데이터 준비
const updateData: any = { const updates: string[] = [];
...data, const updateParams: any[] = [];
updated_date: new Date(), let paramIndex = 1;
};
// 각 필드를 동적으로 추가
const fields = [
"connection_name",
"description",
"db_type",
"host",
"port",
"database_name",
"username",
"connection_timeout",
"query_timeout",
"max_connections",
"ssl_enabled",
"ssl_cert_path",
"connection_options",
"company_code",
"is_active",
"updated_by",
];
for (const field of fields) {
if (data[field as keyof ExternalDbConnection] !== undefined) {
updates.push(`${field} = $${paramIndex++}`);
const value = data[field as keyof ExternalDbConnection];
updateParams.push(
field === "connection_options" ? JSON.stringify(value) : value
);
}
}
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후) // 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
if (data.password && data.password !== "***ENCRYPTED***") { if (data.password && data.password !== "***ENCRYPTED***") {
updateData.password = PasswordEncryption.encrypt(data.password); updates.push(`password = $${paramIndex++}`);
} else { updateParams.push(PasswordEncryption.encrypt(data.password));
// 비밀번호 필드 제거 (변경하지 않음)
delete updateData.password;
} }
const updatedConnection = await prisma.external_db_connections.update({ // updated_date는 항상 업데이트
where: { id }, updates.push(`updated_date = NOW()`);
data: updateData,
}); // id 파라미터 추가
updateParams.push(id);
const updatedConnection = await queryOne<any>(
`UPDATE external_db_connections
SET ${updates.join(", ")}
WHERE id = $${paramIndex}
RETURNING *`,
updateParams
);
// 비밀번호는 반환하지 않음 // 비밀번호는 반환하지 않음
const safeConnection = { const safeConnection = {
@ -451,10 +498,10 @@ export class ExternalDbConnectionService {
*/ */
static async deleteConnection(id: number): Promise<ApiResponse<void>> { static async deleteConnection(id: number): Promise<ApiResponse<void>> {
try { try {
const existingConnection = const existingConnection = await queryOne(
await prisma.external_db_connections.findUnique({ `SELECT id FROM external_db_connections WHERE id = $1`,
where: { id }, [id]
}); );
if (!existingConnection) { if (!existingConnection) {
return { return {
@ -464,9 +511,7 @@ export class ExternalDbConnectionService {
} }
// 물리 삭제 (실제 데이터 삭제) // 물리 삭제 (실제 데이터 삭제)
await prisma.external_db_connections.delete({ await query(`DELETE FROM external_db_connections WHERE id = $1`, [id]);
where: { id },
});
return { return {
success: true, success: true,
@ -491,9 +536,10 @@ export class ExternalDbConnectionService {
): Promise<import("../types/externalDbTypes").ConnectionTestResult> { ): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
try { try {
// 저장된 연결 정보 조회 // 저장된 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<any>(
where: { id }, `SELECT * FROM external_db_connections WHERE id = $1`,
}); [id]
);
if (!connection) { if (!connection) {
return { return {
@ -674,10 +720,10 @@ export class ExternalDbConnectionService {
*/ */
static async getDecryptedPassword(id: number): Promise<string | null> { static async getDecryptedPassword(id: number): Promise<string | null> {
try { try {
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<{ password: string }>(
where: { id }, `SELECT password FROM external_db_connections WHERE id = $1`,
select: { password: true }, [id]
}); );
if (!connection) { if (!connection) {
return null; return null;
@ -725,9 +771,10 @@ export class ExternalDbConnectionService {
// 연결 정보 조회 // 연결 정보 조회
console.log("연결 정보 조회 시작:", { id }); console.log("연결 정보 조회 시작:", { id });
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<any>(
where: { id }, `SELECT * FROM external_db_connections WHERE id = $1`,
}); [id]
);
console.log("조회된 연결 정보:", connection); console.log("조회된 연결 정보:", connection);
if (!connection) { if (!connection) {
@ -777,14 +824,25 @@ export class ExternalDbConnectionService {
let result; let result;
try { try {
const dbType = connection.db_type?.toLowerCase() || 'postgresql'; const dbType = connection.db_type?.toLowerCase() || "postgresql";
// 파라미터 바인딩을 지원하는 DB 타입들 // 파라미터 바인딩을 지원하는 DB 타입들
const supportedDbTypes = ['oracle', 'mysql', 'mariadb', 'postgresql', 'sqlite', 'sqlserver', 'mssql']; const supportedDbTypes = [
"oracle",
"mysql",
"mariadb",
"postgresql",
"sqlite",
"sqlserver",
"mssql",
];
if (supportedDbTypes.includes(dbType) && params.length > 0) { if (supportedDbTypes.includes(dbType) && params.length > 0) {
// 파라미터 바인딩 지원 DB: 안전한 파라미터 바인딩 사용 // 파라미터 바인딩 지원 DB: 안전한 파라미터 바인딩 사용
logger.info(`${dbType.toUpperCase()} 파라미터 바인딩 실행:`, { query, params }); logger.info(`${dbType.toUpperCase()} 파라미터 바인딩 실행:`, {
query,
params,
});
result = await (connector as any).executeQuery(query, params); result = await (connector as any).executeQuery(query, params);
} else { } else {
// 파라미터가 없거나 지원하지 않는 DB: 기본 방식 사용 // 파라미터가 없거나 지원하지 않는 DB: 기본 방식 사용
@ -870,9 +928,10 @@ export class ExternalDbConnectionService {
static async getTables(id: number): Promise<ApiResponse<TableInfo[]>> { static async getTables(id: number): Promise<ApiResponse<TableInfo[]>> {
try { try {
// 연결 정보 조회 // 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({ const connection = await queryOne<any>(
where: { id }, `SELECT * FROM external_db_connections WHERE id = $1`,
}); [id]
);
if (!connection) { if (!connection) {
return { return {

View File

@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne } from "../database/db";
import prisma from "../config/database";
import { import {
CreateLayoutRequest, CreateLayoutRequest,
UpdateLayoutRequest, UpdateLayoutRequest,
@ -77,42 +76,59 @@ export class LayoutService {
const skip = (page - 1) * size; const skip = (page - 1) * size;
// 검색 조건 구성 // 동적 WHERE 조건 구성
const where: any = { const whereConditions: string[] = ["is_active = $1"];
is_active: "Y", const values: any[] = ["Y"];
OR: [ let paramIndex = 2;
{ company_code: companyCode },
...(includePublic ? [{ is_public: "Y" }] : []), // company_code OR is_public 조건
], if (includePublic) {
}; whereConditions.push(
`(company_code = $${paramIndex} OR is_public = $${paramIndex + 1})`
);
values.push(companyCode, "Y");
paramIndex += 2;
} else {
whereConditions.push(`company_code = $${paramIndex++}`);
values.push(companyCode);
}
if (category) { if (category) {
where.category = category; whereConditions.push(`category = $${paramIndex++}`);
values.push(category);
} }
if (layoutType) { if (layoutType) {
where.layout_type = layoutType; whereConditions.push(`layout_type = $${paramIndex++}`);
values.push(layoutType);
} }
if (searchTerm) { if (searchTerm) {
where.OR = [ whereConditions.push(
...where.OR, `(layout_name ILIKE $${paramIndex} OR layout_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
{ layout_name: { contains: searchTerm, mode: "insensitive" } }, );
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } }, values.push(`%${searchTerm}%`);
{ description: { contains: searchTerm, mode: "insensitive" } }, paramIndex++;
];
} }
const [data, total] = await Promise.all([ const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
prisma.layout_standards.findMany({
where, const [data, countResult] = await Promise.all([
skip, query<any>(
take: size, `SELECT * FROM layout_standards
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }], ${whereClause}
}), ORDER BY sort_order ASC, created_date DESC
prisma.layout_standards.count({ where }), LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...values, size, skip]
),
queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
values
),
]); ]);
const total = parseInt(countResult?.count || "0");
return { return {
data: data.map( data: data.map(
(layout) => (layout) =>
@ -149,13 +165,13 @@ export class LayoutService {
layoutCode: string, layoutCode: string,
companyCode: string companyCode: string
): Promise<LayoutStandard | null> { ): Promise<LayoutStandard | null> {
const layout = await prisma.layout_standards.findFirst({ const layout = await queryOne<any>(
where: { `SELECT * FROM layout_standards
layout_code: layoutCode, WHERE layout_code = $1 AND is_active = $2
is_active: "Y", AND (company_code = $3 OR is_public = $4)
OR: [{ company_code: companyCode }, { is_public: "Y" }], LIMIT 1`,
}, [layoutCode, "Y", companyCode, "Y"]
}); );
if (!layout) return null; if (!layout) return null;
@ -196,24 +212,31 @@ export class LayoutService {
companyCode companyCode
); );
const layout = await prisma.layout_standards.create({ const layout = await queryOne<any>(
data: { `INSERT INTO layout_standards
layout_code: layoutCode, (layout_code, layout_name, layout_name_eng, description, layout_type, category,
layout_name: request.layoutName, icon_name, default_size, layout_config, zones_config, is_public, is_active,
layout_name_eng: request.layoutNameEng, company_code, created_by, updated_by, created_date, updated_date, sort_order)
description: request.description, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW(), 0)
layout_type: request.layoutType, RETURNING *`,
category: request.category, [
icon_name: request.iconName, layoutCode,
default_size: safeJSONStringify(request.defaultSize) as any, request.layoutName,
layout_config: safeJSONStringify(request.layoutConfig) as any, request.layoutNameEng,
zones_config: safeJSONStringify(request.zonesConfig) as any, request.description,
is_public: request.isPublic ? "Y" : "N", request.layoutType,
company_code: companyCode, request.category,
created_by: userId, request.iconName,
updated_by: userId, safeJSONStringify(request.defaultSize),
}, safeJSONStringify(request.layoutConfig),
}); safeJSONStringify(request.zonesConfig),
request.isPublic ? "Y" : "N",
"Y",
companyCode,
userId,
userId,
]
);
return this.mapToLayoutStandard(layout); return this.mapToLayoutStandard(layout);
} }
@ -227,47 +250,69 @@ export class LayoutService {
userId: string userId: string
): Promise<LayoutStandard | null> { ): Promise<LayoutStandard | null> {
// 수정 권한 확인 // 수정 권한 확인
const existing = await prisma.layout_standards.findFirst({ const existing = await queryOne<any>(
where: { `SELECT * FROM layout_standards
layout_code: request.layoutCode, WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
company_code: companyCode, [request.layoutCode, companyCode, "Y"]
is_active: "Y", );
},
});
if (!existing) { if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다."); throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
} }
const updateData: any = { // 동적 UPDATE 쿼리 생성
updated_by: userId, const updateFields: string[] = ["updated_by = $1", "updated_date = NOW()"];
updated_date: new Date(), const values: any[] = [userId];
}; let paramIndex = 2;
// 수정할 필드만 업데이트 if (request.layoutName !== undefined) {
if (request.layoutName !== undefined) updateFields.push(`layout_name = $${paramIndex++}`);
updateData.layout_name = request.layoutName; values.push(request.layoutName);
if (request.layoutNameEng !== undefined) }
updateData.layout_name_eng = request.layoutNameEng; if (request.layoutNameEng !== undefined) {
if (request.description !== undefined) updateFields.push(`layout_name_eng = $${paramIndex++}`);
updateData.description = request.description; values.push(request.layoutNameEng);
if (request.layoutType !== undefined) }
updateData.layout_type = request.layoutType; if (request.description !== undefined) {
if (request.category !== undefined) updateData.category = request.category; updateFields.push(`description = $${paramIndex++}`);
if (request.iconName !== undefined) updateData.icon_name = request.iconName; values.push(request.description);
if (request.defaultSize !== undefined) }
updateData.default_size = safeJSONStringify(request.defaultSize) as any; if (request.layoutType !== undefined) {
if (request.layoutConfig !== undefined) updateFields.push(`layout_type = $${paramIndex++}`);
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any; values.push(request.layoutType);
if (request.zonesConfig !== undefined) }
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any; if (request.category !== undefined) {
if (request.isPublic !== undefined) updateFields.push(`category = $${paramIndex++}`);
updateData.is_public = request.isPublic ? "Y" : "N"; values.push(request.category);
}
if (request.iconName !== undefined) {
updateFields.push(`icon_name = $${paramIndex++}`);
values.push(request.iconName);
}
if (request.defaultSize !== undefined) {
updateFields.push(`default_size = $${paramIndex++}`);
values.push(safeJSONStringify(request.defaultSize));
}
if (request.layoutConfig !== undefined) {
updateFields.push(`layout_config = $${paramIndex++}`);
values.push(safeJSONStringify(request.layoutConfig));
}
if (request.zonesConfig !== undefined) {
updateFields.push(`zones_config = $${paramIndex++}`);
values.push(safeJSONStringify(request.zonesConfig));
}
if (request.isPublic !== undefined) {
updateFields.push(`is_public = $${paramIndex++}`);
values.push(request.isPublic ? "Y" : "N");
}
const updated = await prisma.layout_standards.update({ const updated = await queryOne<any>(
where: { layout_code: request.layoutCode }, `UPDATE layout_standards
data: updateData, SET ${updateFields.join(", ")}
}); WHERE layout_code = $${paramIndex}
RETURNING *`,
[...values, request.layoutCode]
);
return this.mapToLayoutStandard(updated); return this.mapToLayoutStandard(updated);
} }
@ -280,26 +325,22 @@ export class LayoutService {
companyCode: string, companyCode: string,
userId: string userId: string
): Promise<boolean> { ): Promise<boolean> {
const existing = await prisma.layout_standards.findFirst({ const existing = await queryOne<any>(
where: { `SELECT * FROM layout_standards
layout_code: layoutCode, WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
company_code: companyCode, [layoutCode, companyCode, "Y"]
is_active: "Y", );
},
});
if (!existing) { if (!existing) {
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다."); throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
} }
await prisma.layout_standards.update({ await query(
where: { layout_code: layoutCode }, `UPDATE layout_standards
data: { SET is_active = $1, updated_by = $2, updated_date = NOW()
is_active: "N", WHERE layout_code = $3`,
updated_by: userId, ["N", userId, layoutCode]
updated_date: new Date(), );
},
});
return true; return true;
} }
@ -342,20 +383,17 @@ export class LayoutService {
async getLayoutCountsByCategory( async getLayoutCountsByCategory(
companyCode: string companyCode: string
): Promise<Record<string, number>> { ): Promise<Record<string, number>> {
const counts = await prisma.layout_standards.groupBy({ const counts = await query<{ category: string; count: string }>(
by: ["category"], `SELECT category, COUNT(*) as count
_count: { FROM layout_standards
layout_code: true, WHERE is_active = $1 AND (company_code = $2 OR is_public = $3)
}, GROUP BY category`,
where: { ["Y", companyCode, "Y"]
is_active: "Y", );
OR: [{ company_code: companyCode }, { is_public: "Y" }],
},
});
return counts.reduce( return counts.reduce(
(acc: Record<string, number>, item: any) => { (acc: Record<string, number>, item: any) => {
acc[item.category] = item._count.layout_code; acc[item.category] = parseInt(item.count);
return acc; return acc;
}, },
{} as Record<string, number> {} as Record<string, number>
@ -370,16 +408,11 @@ export class LayoutService {
companyCode: string companyCode: string
): Promise<string> { ): Promise<string> {
const prefix = `${layoutType.toUpperCase()}_${companyCode}`; const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
const existingCodes = await prisma.layout_standards.findMany({ const existingCodes = await query<{ layout_code: string }>(
where: { `SELECT layout_code FROM layout_standards
layout_code: { WHERE layout_code LIKE $1`,
startsWith: prefix, [`${prefix}%`]
}, );
},
select: {
layout_code: true,
},
});
const maxNumber = existingCodes.reduce((max: number, item: any) => { const maxNumber = existingCodes.reduce((max: number, item: any) => {
const match = item.layout_code.match(/_(\d+)$/); const match = item.layout_code.match(/_(\d+)$/);

View File

@ -119,25 +119,22 @@ export class MultiConnectionQueryService {
} }
/** /**
* ( DB ) *
*/ */
async insertDataToConnection( async insertDataToConnection(
connectionId: number, connectionId: number,
tableName: string, tableName: string,
data: Record<string, any> data: Record<string, any>
): Promise<any> { ): Promise<any> {
// 보안상 외부 DB에 대한 INSERT 작업은 비활성화
if (connectionId !== 0) {
throw new Error("보안상 외부 데이터베이스에 대한 INSERT 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
}
try { try {
logger.info( logger.info(
`데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}` `데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}`
); );
// connectionId가 0이면 메인 DB 사용 (내부 DB만 허용) // connectionId가 0이면 메인 DB 사용
if (connectionId === 0) {
return await this.executeOnMainDatabase("insert", tableName, data); return await this.executeOnMainDatabase("insert", tableName, data);
}
// 외부 DB 연결 정보 가져오기 // 외부 DB 연결 정보 가져오기
const connectionResult = const connectionResult =
@ -152,7 +149,7 @@ export class MultiConnectionQueryService {
let values = Object.values(data); let values = Object.values(data);
// Oracle의 경우 테이블 스키마 확인 및 데이터 타입 변환 처리 // Oracle의 경우 테이블 스키마 확인 및 데이터 타입 변환 처리
if (connection?.db_type?.toLowerCase() === 'oracle') { if (connection.db_type?.toLowerCase() === "oracle") {
try { try {
// Oracle 테이블 스키마 조회 // Oracle 테이블 스키마 조회
const schemaQuery = ` const schemaQuery = `
@ -171,41 +168,52 @@ export class MultiConnectionQueryService {
if (schemaResult.success && schemaResult.data) { if (schemaResult.success && schemaResult.data) {
logger.info(`📋 Oracle 테이블 ${tableName} 스키마:`); logger.info(`📋 Oracle 테이블 ${tableName} 스키마:`);
schemaResult.data!.forEach((col: any) => { schemaResult.data.forEach((col: any) => {
logger.info(` - ${col.COLUMN_NAME}: ${col.DATA_TYPE}, NULL: ${col.NULLABLE}, DEFAULT: ${col.DATA_DEFAULT || 'None'}`); logger.info(
` - ${col.COLUMN_NAME}: ${col.DATA_TYPE}, NULL: ${col.NULLABLE}, DEFAULT: ${col.DATA_DEFAULT || "None"}`
);
}); });
// 필수 컬럼 중 누락된 컬럼이 있는지 확인 (기본값이 없는 NOT NULL 컬럼만) // 필수 컬럼 중 누락된 컬럼이 있는지 확인 (기본값이 없는 NOT NULL 컬럼만)
const providedColumns = columns.map(col => col.toUpperCase()); const providedColumns = columns.map((col) => col.toUpperCase());
const missingRequiredColumns = schemaResult.data!.filter((schemaCol: any) => const missingRequiredColumns = schemaResult.data.filter(
schemaCol.NULLABLE === 'N' && (schemaCol: any) =>
schemaCol.NULLABLE === "N" &&
!schemaCol.DATA_DEFAULT && !schemaCol.DATA_DEFAULT &&
!providedColumns.includes(schemaCol.COLUMN_NAME) !providedColumns.includes(schemaCol.COLUMN_NAME)
); );
if (missingRequiredColumns.length > 0) { if (missingRequiredColumns.length > 0) {
const missingNames = missingRequiredColumns.map((col: any) => col.COLUMN_NAME); const missingNames = missingRequiredColumns.map(
logger.error(`❌ 필수 컬럼 누락: ${missingNames.join(', ')}`); (col: any) => col.COLUMN_NAME
throw new Error(`필수 컬럼이 누락되었습니다: ${missingNames.join(', ')}`); );
logger.error(`❌ 필수 컬럼 누락: ${missingNames.join(", ")}`);
throw new Error(
`필수 컬럼이 누락되었습니다: ${missingNames.join(", ")}`
);
} }
logger.info(`✅ 스키마 검증 통과: 모든 필수 컬럼이 제공되었거나 기본값이 있습니다.`); logger.info(
`✅ 스키마 검증 통과: 모든 필수 컬럼이 제공되었거나 기본값이 있습니다.`
);
} }
} catch (schemaError) { } catch (schemaError) {
logger.warn(`⚠️ 스키마 조회 실패 (계속 진행): ${schemaError}`); logger.warn(`⚠️ 스키마 조회 실패 (계속 진행): ${schemaError}`);
} }
values = values.map(value => { values = values.map((value) => {
// null이나 undefined는 그대로 유지 // null이나 undefined는 그대로 유지
if (value === null || value === undefined) { if (value === null || value === undefined) {
return value; return value;
} }
// 숫자로 변환 가능한 문자열은 숫자로 변환 // 숫자로 변환 가능한 문자열은 숫자로 변환
if (typeof value === 'string' && value.trim() !== '') { if (typeof value === "string" && value.trim() !== "") {
const numValue = Number(value); const numValue = Number(value);
if (!isNaN(numValue)) { if (!isNaN(numValue)) {
logger.info(`🔄 Oracle 데이터 타입 변환: "${value}" (string) → ${numValue} (number)`); logger.info(
`🔄 Oracle 데이터 타입 변환: "${value}" (string) → ${numValue} (number)`
);
return numValue; return numValue;
} }
} }
@ -216,12 +224,14 @@ export class MultiConnectionQueryService {
let query: string; let query: string;
let queryParams: any[]; let queryParams: any[];
const dbType = connection?.db_type?.toLowerCase() || 'postgresql'; const dbType = connection.db_type?.toLowerCase() || "postgresql";
switch (dbType) { switch (dbType) {
case 'oracle': case "oracle":
// Oracle: :1, :2 스타일 바인딩 사용, RETURNING 미지원 // Oracle: :1, :2 스타일 바인딩 사용, RETURNING 미지원
const oraclePlaceholders = values.map((_, index) => `:${index + 1}`).join(", "); const oraclePlaceholders = values
.map((_, index) => `:${index + 1}`)
.join(", ");
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${oraclePlaceholders})`; query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${oraclePlaceholders})`;
queryParams = values; queryParams = values;
logger.info(`🔍 Oracle INSERT 상세 정보:`); logger.info(`🔍 Oracle INSERT 상세 정보:`);
@ -230,42 +240,57 @@ export class MultiConnectionQueryService {
logger.info(` - 값: ${JSON.stringify(values)}`); logger.info(` - 값: ${JSON.stringify(values)}`);
logger.info(` - 쿼리: ${query}`); logger.info(` - 쿼리: ${query}`);
logger.info(` - 파라미터: ${JSON.stringify(queryParams)}`); logger.info(` - 파라미터: ${JSON.stringify(queryParams)}`);
logger.info(` - 데이터 타입: ${JSON.stringify(values.map(v => typeof v))}`); logger.info(
` - 데이터 타입: ${JSON.stringify(values.map((v) => typeof v))}`
);
break; break;
case 'mysql': case "mysql":
case 'mariadb': case "mariadb":
// MySQL/MariaDB: ? 스타일 바인딩 사용, RETURNING 미지원 // MySQL/MariaDB: ? 스타일 바인딩 사용, RETURNING 미지원
const mysqlPlaceholders = values.map(() => '?').join(", "); const mysqlPlaceholders = values.map(() => "?").join(", ");
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${mysqlPlaceholders})`; query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${mysqlPlaceholders})`;
queryParams = values; queryParams = values;
logger.info(`MySQL/MariaDB INSERT 쿼리:`, { query, params: queryParams }); logger.info(`MySQL/MariaDB INSERT 쿼리:`, {
query,
params: queryParams,
});
break; break;
case 'sqlserver': case "sqlserver":
case 'mssql': case "mssql":
// SQL Server: @param1, @param2 스타일 바인딩 사용 // SQL Server: @param1, @param2 스타일 바인딩 사용
const sqlServerPlaceholders = values.map((_, index) => `@param${index + 1}`).join(", "); const sqlServerPlaceholders = values
.map((_, index) => `@param${index + 1}`)
.join(", ");
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlServerPlaceholders})`; query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlServerPlaceholders})`;
queryParams = values; queryParams = values;
logger.info(`SQL Server INSERT 쿼리:`, { query, params: queryParams }); logger.info(`SQL Server INSERT 쿼리:`, {
query,
params: queryParams,
});
break; break;
case 'sqlite': case "sqlite":
// SQLite: ? 스타일 바인딩 사용, RETURNING 지원 (3.35.0+) // SQLite: ? 스타일 바인딩 사용, RETURNING 지원 (3.35.0+)
const sqlitePlaceholders = values.map(() => '?').join(", "); const sqlitePlaceholders = values.map(() => "?").join(", ");
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlitePlaceholders}) RETURNING *`; query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlitePlaceholders}) RETURNING *`;
queryParams = values; queryParams = values;
logger.info(`SQLite INSERT 쿼리:`, { query, params: queryParams }); logger.info(`SQLite INSERT 쿼리:`, { query, params: queryParams });
break; break;
case 'postgresql': case "postgresql":
default: default:
// PostgreSQL: $1, $2 스타일 바인딩 사용, RETURNING 지원 // PostgreSQL: $1, $2 스타일 바인딩 사용, RETURNING 지원
const pgPlaceholders = values.map((_, index) => `$${index + 1}`).join(", "); const pgPlaceholders = values
.map((_, index) => `$${index + 1}`)
.join(", ");
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${pgPlaceholders}) RETURNING *`; query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${pgPlaceholders}) RETURNING *`;
queryParams = values; queryParams = values;
logger.info(`PostgreSQL INSERT 쿼리:`, { query, params: queryParams }); logger.info(`PostgreSQL INSERT 쿼리:`, {
query,
params: queryParams,
});
break; break;
} }
@ -281,7 +306,7 @@ export class MultiConnectionQueryService {
} }
logger.info(`데이터 삽입 완료`); logger.info(`데이터 삽입 완료`);
return result.data?.[0] || result.data || []; return result.data[0] || result.data;
} catch (error) { } catch (error) {
logger.error(`데이터 삽입 실패: ${error}`); logger.error(`데이터 삽입 실패: ${error}`);
throw new Error( throw new Error(
@ -291,7 +316,7 @@ export class MultiConnectionQueryService {
} }
/** /**
* 🆕 ( DB ) * 🆕
*/ */
async updateDataToConnection( async updateDataToConnection(
connectionId: number, connectionId: number,
@ -299,11 +324,6 @@ export class MultiConnectionQueryService {
data: Record<string, any>, data: Record<string, any>,
conditions: Record<string, any> conditions: Record<string, any>
): Promise<any> { ): Promise<any> {
// 보안상 외부 DB에 대한 UPDATE 작업은 비활성화
if (connectionId !== 0) {
throw new Error("보안상 외부 데이터베이스에 대한 UPDATE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
}
try { try {
logger.info( logger.info(
`데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}` `데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}`
@ -386,7 +406,7 @@ export class MultiConnectionQueryService {
} }
/** /**
* 🆕 ( DB ) * 🆕
*/ */
async deleteDataFromConnection( async deleteDataFromConnection(
connectionId: number, connectionId: number,
@ -394,11 +414,6 @@ export class MultiConnectionQueryService {
conditions: Record<string, any>, conditions: Record<string, any>,
maxDeleteCount: number = 100 maxDeleteCount: number = 100
): Promise<any> { ): Promise<any> {
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
if (connectionId !== 0) {
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
}
try { try {
logger.info( logger.info(
`데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}` `데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}`

View File

@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client"; import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { import {
Language, Language,
@ -15,8 +15,6 @@ import {
ApiResponse, ApiResponse,
} from "../types/multilang"; } from "../types/multilang";
const prisma = new PrismaClient();
export class MultiLangService { export class MultiLangService {
constructor() {} constructor() {}
@ -27,25 +25,27 @@ export class MultiLangService {
try { try {
logger.info("언어 목록 조회 시작"); logger.info("언어 목록 조회 시작");
const languages = await prisma.language_master.findMany({ const languages = await query<{
orderBy: [{ sort_order: "asc" }, { lang_code: "asc" }], lang_code: string;
select: { lang_name: string;
lang_code: true, lang_native: string | null;
lang_name: true, is_active: string | null;
lang_native: true, sort_order: number | null;
is_active: true, created_date: Date | null;
sort_order: true, created_by: string | null;
created_date: true, updated_date: Date | null;
created_by: true, updated_by: string | null;
updated_date: true, }>(
updated_by: true, `SELECT lang_code, lang_name, lang_native, is_active, sort_order,
}, created_date, created_by, updated_date, updated_by
}); FROM language_master
ORDER BY sort_order ASC, lang_code ASC`
);
const mappedLanguages: Language[] = languages.map((lang) => ({ const mappedLanguages: Language[] = languages.map((lang) => ({
langCode: lang.lang_code, langCode: lang.lang_code,
langName: lang.lang_name, langName: lang.lang_name,
langNative: lang.lang_native, langNative: lang.lang_native || "",
isActive: lang.is_active || "N", isActive: lang.is_active || "N",
sortOrder: lang.sort_order ?? undefined, sortOrder: lang.sort_order ?? undefined,
createdDate: lang.created_date || undefined, createdDate: lang.created_date || undefined,
@ -72,9 +72,10 @@ export class MultiLangService {
logger.info("언어 생성 시작", { languageData }); logger.info("언어 생성 시작", { languageData });
// 중복 체크 // 중복 체크
const existingLanguage = await prisma.language_master.findUnique({ const existingLanguage = await queryOne<{ lang_code: string }>(
where: { lang_code: languageData.langCode }, `SELECT lang_code FROM language_master WHERE lang_code = $1`,
}); [languageData.langCode]
);
if (existingLanguage) { if (existingLanguage) {
throw new Error( throw new Error(
@ -83,30 +84,44 @@ export class MultiLangService {
} }
// 언어 생성 // 언어 생성
const createdLanguage = await prisma.language_master.create({ const createdLanguage = await queryOne<{
data: { lang_code: string;
lang_code: languageData.langCode, lang_name: string;
lang_name: languageData.langName, lang_native: string | null;
lang_native: languageData.langNative, is_active: string | null;
is_active: languageData.isActive || "Y", sort_order: number | null;
sort_order: languageData.sortOrder || 0, created_date: Date | null;
created_by: languageData.createdBy || "system", created_by: string | null;
updated_by: languageData.updatedBy || "system", updated_date: Date | null;
}, updated_by: string | null;
}); }>(
`INSERT INTO language_master
(lang_code, lang_name, lang_native, is_active, sort_order, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
languageData.langCode,
languageData.langName,
languageData.langNative,
languageData.isActive || "Y",
languageData.sortOrder || 0,
languageData.createdBy || "system",
languageData.updatedBy || "system",
]
);
logger.info("언어 생성 완료", { langCode: createdLanguage.lang_code }); logger.info("언어 생성 완료", { langCode: createdLanguage!.lang_code });
return { return {
langCode: createdLanguage.lang_code, langCode: createdLanguage!.lang_code,
langName: createdLanguage.lang_name, langName: createdLanguage!.lang_name,
langNative: createdLanguage.lang_native, langNative: createdLanguage!.lang_native || "",
isActive: createdLanguage.is_active || "N", isActive: createdLanguage!.is_active || "N",
sortOrder: createdLanguage.sort_order ?? undefined, sortOrder: createdLanguage!.sort_order ?? undefined,
createdDate: createdLanguage.created_date || undefined, createdDate: createdLanguage!.created_date || undefined,
createdBy: createdLanguage.created_by || undefined, createdBy: createdLanguage!.created_by || undefined,
updatedDate: createdLanguage.updated_date || undefined, updatedDate: createdLanguage!.updated_date || undefined,
updatedBy: createdLanguage.updated_by || undefined, updatedBy: createdLanguage!.updated_by || undefined,
}; };
} catch (error) { } catch (error) {
logger.error("언어 생성 중 오류 발생:", error); logger.error("언어 생성 중 오류 발생:", error);
@ -127,42 +142,72 @@ export class MultiLangService {
logger.info("언어 수정 시작", { langCode, languageData }); logger.info("언어 수정 시작", { langCode, languageData });
// 기존 언어 확인 // 기존 언어 확인
const existingLanguage = await prisma.language_master.findUnique({ const existingLanguage = await queryOne<{ lang_code: string }>(
where: { lang_code: langCode }, `SELECT lang_code FROM language_master WHERE lang_code = $1`,
}); [langCode]
);
if (!existingLanguage) { if (!existingLanguage) {
throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); throw new Error(`언어를 찾을 수 없습니다: ${langCode}`);
} }
// 동적 UPDATE 쿼리 생성
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (languageData.langName) {
updates.push(`lang_name = $${paramIndex++}`);
values.push(languageData.langName);
}
if (languageData.langNative) {
updates.push(`lang_native = $${paramIndex++}`);
values.push(languageData.langNative);
}
if (languageData.isActive) {
updates.push(`is_active = $${paramIndex++}`);
values.push(languageData.isActive);
}
if (languageData.sortOrder !== undefined) {
updates.push(`sort_order = $${paramIndex++}`);
values.push(languageData.sortOrder);
}
updates.push(`updated_by = $${paramIndex++}`);
values.push(languageData.updatedBy || "system");
values.push(langCode); // WHERE 조건용
// 언어 수정 // 언어 수정
const updatedLanguage = await prisma.language_master.update({ const updatedLanguage = await queryOne<{
where: { lang_code: langCode }, lang_code: string;
data: { lang_name: string;
...(languageData.langName && { lang_name: languageData.langName }), lang_native: string | null;
...(languageData.langNative && { is_active: string | null;
lang_native: languageData.langNative, sort_order: number | null;
}), created_date: Date | null;
...(languageData.isActive && { is_active: languageData.isActive }), created_by: string | null;
...(languageData.sortOrder !== undefined && { updated_date: Date | null;
sort_order: languageData.sortOrder, updated_by: string | null;
}), }>(
updated_by: languageData.updatedBy || "system", `UPDATE language_master SET ${updates.join(", ")}
}, WHERE lang_code = $${paramIndex}
}); RETURNING *`,
values
);
logger.info("언어 수정 완료", { langCode }); logger.info("언어 수정 완료", { langCode });
return { return {
langCode: updatedLanguage.lang_code, langCode: updatedLanguage!.lang_code,
langName: updatedLanguage.lang_name, langName: updatedLanguage!.lang_name,
langNative: updatedLanguage.lang_native, langNative: updatedLanguage!.lang_native || "",
isActive: updatedLanguage.is_active || "N", isActive: updatedLanguage!.is_active || "N",
sortOrder: updatedLanguage.sort_order ?? undefined, sortOrder: updatedLanguage!.sort_order ?? undefined,
createdDate: updatedLanguage.created_date || undefined, createdDate: updatedLanguage!.created_date || undefined,
createdBy: updatedLanguage.created_by || undefined, createdBy: updatedLanguage!.created_by || undefined,
updatedDate: updatedLanguage.updated_date || undefined, updatedDate: updatedLanguage!.updated_date || undefined,
updatedBy: updatedLanguage.updated_by || undefined, updatedBy: updatedLanguage!.updated_by || undefined,
}; };
} catch (error) { } catch (error) {
logger.error("언어 수정 중 오류 발생:", error); logger.error("언어 수정 중 오류 발생:", error);
@ -180,10 +225,10 @@ export class MultiLangService {
logger.info("언어 상태 토글 시작", { langCode }); logger.info("언어 상태 토글 시작", { langCode });
// 현재 언어 조회 // 현재 언어 조회
const currentLanguage = await prisma.language_master.findUnique({ const currentLanguage = await queryOne<{ is_active: string | null }>(
where: { lang_code: langCode }, `SELECT is_active FROM language_master WHERE lang_code = $1`,
select: { is_active: true }, [langCode]
}); );
if (!currentLanguage) { if (!currentLanguage) {
throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); throw new Error(`언어를 찾을 수 없습니다: ${langCode}`);
@ -192,13 +237,12 @@ export class MultiLangService {
const newStatus = currentLanguage.is_active === "Y" ? "N" : "Y"; const newStatus = currentLanguage.is_active === "Y" ? "N" : "Y";
// 상태 업데이트 // 상태 업데이트
await prisma.language_master.update({ await query(
where: { lang_code: langCode }, `UPDATE language_master
data: { SET is_active = $1, updated_by = $2
is_active: newStatus, WHERE lang_code = $3`,
updated_by: "system", [newStatus, "system", langCode]
}, );
});
const result = newStatus === "Y" ? "활성화" : "비활성화"; const result = newStatus === "Y" ? "활성화" : "비활성화";
logger.info("언어 상태 토글 완료", { langCode, result }); logger.info("언어 상태 토글 완료", { langCode, result });
@ -219,47 +263,55 @@ export class MultiLangService {
try { try {
logger.info("다국어 키 목록 조회 시작", { params }); logger.info("다국어 키 목록 조회 시작", { params });
const whereConditions: any = {}; const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 // 회사 코드 필터
if (params.companyCode) { if (params.companyCode) {
whereConditions.company_code = params.companyCode; whereConditions.push(`company_code = $${paramIndex++}`);
values.push(params.companyCode);
} }
// 메뉴 코드 필터 // 메뉴 코드 필터
if (params.menuCode) { if (params.menuCode) {
whereConditions.menu_name = params.menuCode; whereConditions.push(`menu_name = $${paramIndex++}`);
values.push(params.menuCode);
} }
// 검색 조건 // 검색 조건 (OR)
if (params.searchText) { if (params.searchText) {
whereConditions.OR = [ whereConditions.push(
{ lang_key: { contains: params.searchText, mode: "insensitive" } }, `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR menu_name ILIKE $${paramIndex})`
{ description: { contains: params.searchText, mode: "insensitive" } }, );
{ menu_name: { contains: params.searchText, mode: "insensitive" } }, values.push(`%${params.searchText}%`);
]; paramIndex++;
} }
const langKeys = await prisma.multi_lang_key_master.findMany({ const whereClause =
where: whereConditions, whereConditions.length > 0
orderBy: [ ? `WHERE ${whereConditions.join(" AND ")}`
{ company_code: "asc" }, : "";
{ menu_name: "asc" },
{ lang_key: "asc" }, const langKeys = await query<{
], key_id: number;
select: { company_code: string;
key_id: true, menu_name: string | null;
company_code: true, lang_key: string;
menu_name: true, description: string | null;
lang_key: true, is_active: string | null;
description: true, created_date: Date | null;
is_active: true, created_by: string | null;
created_date: true, updated_date: Date | null;
created_by: true, updated_by: string | null;
updated_date: true, }>(
updated_by: true, `SELECT key_id, company_code, menu_name, lang_key, description, is_active,
}, created_date, created_by, updated_date, updated_by
}); FROM multi_lang_key_master
${whereClause}
ORDER BY company_code ASC, menu_name ASC, lang_key ASC`,
values
);
const mappedKeys: LangKey[] = langKeys.map((key) => ({ const mappedKeys: LangKey[] = langKeys.map((key) => ({
keyId: key.key_id, keyId: key.key_id,
@ -291,24 +343,24 @@ export class MultiLangService {
try { try {
logger.info("다국어 텍스트 조회 시작", { keyId }); logger.info("다국어 텍스트 조회 시작", { keyId });
const langTexts = await prisma.multi_lang_text.findMany({ const langTexts = await query<{
where: { text_id: number;
key_id: keyId, key_id: number;
is_active: "Y", lang_code: string;
}, lang_text: string;
orderBy: { lang_code: "asc" }, is_active: string | null;
select: { created_date: Date | null;
text_id: true, created_by: string | null;
key_id: true, updated_date: Date | null;
lang_code: true, updated_by: string | null;
lang_text: true, }>(
is_active: true, `SELECT text_id, key_id, lang_code, lang_text, is_active,
created_date: true, created_date, created_by, updated_date, updated_by
created_by: true, FROM multi_lang_text
updated_date: true, WHERE key_id = $1 AND is_active = $2
updated_by: true, ORDER BY lang_code ASC`,
}, [keyId, "Y"]
}); );
const mappedTexts: LangText[] = langTexts.map((text) => ({ const mappedTexts: LangText[] = langTexts.map((text) => ({
textId: text.text_id, textId: text.text_id,
@ -340,12 +392,11 @@ export class MultiLangService {
logger.info("다국어 키 생성 시작", { keyData }); logger.info("다국어 키 생성 시작", { keyData });
// 중복 체크 // 중복 체크
const existingKey = await prisma.multi_lang_key_master.findFirst({ const existingKey = await queryOne<{ key_id: number }>(
where: { `SELECT key_id FROM multi_lang_key_master
company_code: keyData.companyCode, WHERE company_code = $1 AND lang_key = $2`,
lang_key: keyData.langKey, [keyData.companyCode, keyData.langKey]
}, );
});
if (existingKey) { if (existingKey) {
throw new Error( throw new Error(
@ -354,24 +405,28 @@ export class MultiLangService {
} }
// 다국어 키 생성 // 다국어 키 생성
const createdKey = await prisma.multi_lang_key_master.create({ const createdKey = await queryOne<{ key_id: number }>(
data: { `INSERT INTO multi_lang_key_master
company_code: keyData.companyCode, (company_code, menu_name, lang_key, description, is_active, created_by, updated_by)
menu_name: keyData.menuName || null, VALUES ($1, $2, $3, $4, $5, $6, $7)
lang_key: keyData.langKey, RETURNING key_id`,
description: keyData.description || null, [
is_active: keyData.isActive || "Y", keyData.companyCode,
created_by: keyData.createdBy || "system", keyData.menuName || null,
updated_by: keyData.updatedBy || "system", keyData.langKey,
}, keyData.description || null,
}); keyData.isActive || "Y",
keyData.createdBy || "system",
keyData.updatedBy || "system",
]
);
logger.info("다국어 키 생성 완료", { logger.info("다국어 키 생성 완료", {
keyId: createdKey.key_id, keyId: createdKey!.key_id,
langKey: keyData.langKey, langKey: keyData.langKey,
}); });
return createdKey.key_id; return createdKey!.key_id;
} catch (error) { } catch (error) {
logger.error("다국어 키 생성 중 오류 발생:", error); logger.error("다국어 키 생성 중 오류 발생:", error);
throw new Error( throw new Error(
@ -391,9 +446,10 @@ export class MultiLangService {
logger.info("다국어 키 수정 시작", { keyId, keyData }); logger.info("다국어 키 수정 시작", { keyId, keyData });
// 기존 키 확인 // 기존 키 확인
const existingKey = await prisma.multi_lang_key_master.findUnique({ const existingKey = await queryOne<{ key_id: number }>(
where: { key_id: keyId }, `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`,
}); [keyId]
);
if (!existingKey) { if (!existingKey) {
throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`);
@ -401,13 +457,11 @@ export class MultiLangService {
// 중복 체크 (자신을 제외하고) // 중복 체크 (자신을 제외하고)
if (keyData.companyCode && keyData.langKey) { if (keyData.companyCode && keyData.langKey) {
const duplicateKey = await prisma.multi_lang_key_master.findFirst({ const duplicateKey = await queryOne<{ key_id: number }>(
where: { `SELECT key_id FROM multi_lang_key_master
company_code: keyData.companyCode, WHERE company_code = $1 AND lang_key = $2 AND key_id != $3`,
lang_key: keyData.langKey, [keyData.companyCode, keyData.langKey, keyId]
key_id: { not: keyId }, );
},
});
if (duplicateKey) { if (duplicateKey) {
throw new Error( throw new Error(
@ -416,21 +470,39 @@ export class MultiLangService {
} }
} }
// 동적 UPDATE 쿼리 생성
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (keyData.companyCode) {
updates.push(`company_code = $${paramIndex++}`);
values.push(keyData.companyCode);
}
if (keyData.menuName !== undefined) {
updates.push(`menu_name = $${paramIndex++}`);
values.push(keyData.menuName);
}
if (keyData.langKey) {
updates.push(`lang_key = $${paramIndex++}`);
values.push(keyData.langKey);
}
if (keyData.description !== undefined) {
updates.push(`description = $${paramIndex++}`);
values.push(keyData.description);
}
updates.push(`updated_by = $${paramIndex++}`);
values.push(keyData.updatedBy || "system");
values.push(keyId); // WHERE 조건용
// 다국어 키 수정 // 다국어 키 수정
await prisma.multi_lang_key_master.update({ await query(
where: { key_id: keyId }, `UPDATE multi_lang_key_master SET ${updates.join(", ")}
data: { WHERE key_id = $${paramIndex}`,
...(keyData.companyCode && { company_code: keyData.companyCode }), values
...(keyData.menuName !== undefined && { );
menu_name: keyData.menuName,
}),
...(keyData.langKey && { lang_key: keyData.langKey }),
...(keyData.description !== undefined && {
description: keyData.description,
}),
updated_by: keyData.updatedBy || "system",
},
});
logger.info("다국어 키 수정 완료", { keyId }); logger.info("다국어 키 수정 완료", { keyId });
} catch (error) { } catch (error) {
@ -449,25 +521,27 @@ export class MultiLangService {
logger.info("다국어 키 삭제 시작", { keyId }); logger.info("다국어 키 삭제 시작", { keyId });
// 기존 키 확인 // 기존 키 확인
const existingKey = await prisma.multi_lang_key_master.findUnique({ const existingKey = await queryOne<{ key_id: number }>(
where: { key_id: keyId }, `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`,
}); [keyId]
);
if (!existingKey) { if (!existingKey) {
throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`);
} }
// 트랜잭션으로 키와 연관된 텍스트 모두 삭제 // 트랜잭션으로 키와 연관된 텍스트 모두 삭제
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 관련된 다국어 텍스트 삭제 // 관련된 다국어 텍스트 삭제
await tx.multi_lang_text.deleteMany({ await client.query(`DELETE FROM multi_lang_text WHERE key_id = $1`, [
where: { key_id: keyId }, keyId,
}); ]);
// 다국어 키 삭제 // 다국어 키 삭제
await tx.multi_lang_key_master.delete({ await client.query(
where: { key_id: keyId }, `DELETE FROM multi_lang_key_master WHERE key_id = $1`,
}); [keyId]
);
}); });
logger.info("다국어 키 삭제 완료", { keyId }); logger.info("다국어 키 삭제 완료", { keyId });
@ -487,10 +561,10 @@ export class MultiLangService {
logger.info("다국어 키 상태 토글 시작", { keyId }); logger.info("다국어 키 상태 토글 시작", { keyId });
// 현재 키 조회 // 현재 키 조회
const currentKey = await prisma.multi_lang_key_master.findUnique({ const currentKey = await queryOne<{ is_active: string | null }>(
where: { key_id: keyId }, `SELECT is_active FROM multi_lang_key_master WHERE key_id = $1`,
select: { is_active: true }, [keyId]
}); );
if (!currentKey) { if (!currentKey) {
throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`);
@ -499,13 +573,12 @@ export class MultiLangService {
const newStatus = currentKey.is_active === "Y" ? "N" : "Y"; const newStatus = currentKey.is_active === "Y" ? "N" : "Y";
// 상태 업데이트 // 상태 업데이트
await prisma.multi_lang_key_master.update({ await query(
where: { key_id: keyId }, `UPDATE multi_lang_key_master
data: { SET is_active = $1, updated_by = $2
is_active: newStatus, WHERE key_id = $3`,
updated_by: "system", [newStatus, "system", keyId]
}, );
});
const result = newStatus === "Y" ? "활성화" : "비활성화"; const result = newStatus === "Y" ? "활성화" : "비활성화";
logger.info("다국어 키 상태 토글 완료", { keyId, result }); logger.info("다국어 키 상태 토글 완료", { keyId, result });
@ -533,33 +606,39 @@ export class MultiLangService {
}); });
// 기존 키 확인 // 기존 키 확인
const existingKey = await prisma.multi_lang_key_master.findUnique({ const existingKey = await queryOne<{ key_id: number }>(
where: { key_id: keyId }, `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`,
}); [keyId]
);
if (!existingKey) { if (!existingKey) {
throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`);
} }
// 트랜잭션으로 기존 텍스트 삭제 후 새로 생성 // 트랜잭션으로 기존 텍스트 삭제 후 새로 생성
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 기존 텍스트 삭제 // 기존 텍스트 삭제
await tx.multi_lang_text.deleteMany({ await client.query(`DELETE FROM multi_lang_text WHERE key_id = $1`, [
where: { key_id: keyId }, keyId,
}); ]);
// 새로운 텍스트 삽입 // 새로운 텍스트 삽입
if (textData.texts.length > 0) { if (textData.texts.length > 0) {
await tx.multi_lang_text.createMany({ for (const text of textData.texts) {
data: textData.texts.map((text) => ({ await client.query(
key_id: keyId, `INSERT INTO multi_lang_text
lang_code: text.langCode, (key_id, lang_code, lang_text, is_active, created_by, updated_by)
lang_text: text.langText, VALUES ($1, $2, $3, $4, $5, $6)`,
is_active: text.isActive || "Y", [
created_by: text.createdBy || "system", keyId,
updated_by: text.updatedBy || "system", text.langCode,
})), text.langText,
}); text.isActive || "Y",
text.createdBy || "system",
text.updatedBy || "system",
]
);
}
} }
}); });
@ -582,21 +661,25 @@ export class MultiLangService {
try { try {
logger.info("사용자별 다국어 텍스트 조회 시작", { params }); logger.info("사용자별 다국어 텍스트 조회 시작", { params });
const result = await prisma.multi_lang_text.findFirst({ const result = await queryOne<{ lang_text: string }>(
where: { `SELECT mlt.lang_text
lang_code: params.userLang, FROM multi_lang_text mlt
is_active: "Y", INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id
multi_lang_key_master: { WHERE mlt.lang_code = $1
company_code: params.companyCode, AND mlt.is_active = $2
menu_name: params.menuCode, AND mlkm.company_code = $3
lang_key: params.langKey, AND mlkm.menu_name = $4
is_active: "Y", AND mlkm.lang_key = $5
}, AND mlkm.is_active = $6`,
}, [
select: { params.userLang,
lang_text: true, "Y",
}, params.companyCode,
}); params.menuCode,
params.langKey,
"Y",
]
);
if (!result) { if (!result) {
logger.warn("사용자별 다국어 텍스트를 찾을 수 없음", { params }); logger.warn("사용자별 다국어 텍스트를 찾을 수 없음", { params });
@ -632,20 +715,17 @@ export class MultiLangService {
langCode, langCode,
}); });
const result = await prisma.multi_lang_text.findFirst({ const result = await queryOne<{ lang_text: string }>(
where: { `SELECT mlt.lang_text
lang_code: langCode, FROM multi_lang_text mlt
is_active: "Y", INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id
multi_lang_key_master: { WHERE mlt.lang_code = $1
company_code: companyCode, AND mlt.is_active = $2
lang_key: langKey, AND mlkm.company_code = $3
is_active: "Y", AND mlkm.lang_key = $4
}, AND mlkm.is_active = $5`,
}, [langCode, "Y", companyCode, langKey, "Y"]
select: { );
lang_text: true,
},
});
if (!result) { if (!result) {
logger.warn("특정 키의 다국어 텍스트를 찾을 수 없음", { logger.warn("특정 키의 다국어 텍스트를 찾을 수 없음", {
@ -691,31 +771,26 @@ export class MultiLangService {
} }
// 모든 키에 대한 번역 조회 // 모든 키에 대한 번역 조회
const translations = await prisma.multi_lang_text.findMany({ const placeholders = params.langKeys
where: { .map((_, i) => `$${i + 4}`)
lang_code: params.userLang, .join(", ");
is_active: "Y",
multi_lang_key_master: { const translations = await query<{
lang_key: { in: params.langKeys }, lang_text: string;
company_code: { in: [params.companyCode, "*"] }, lang_key: string;
is_active: "Y", company_code: string;
}, }>(
}, `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code
select: { FROM multi_lang_text mlt
lang_text: true, INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id
multi_lang_key_master: { WHERE mlt.lang_code = $1
select: { AND mlt.is_active = $2
lang_key: true, AND mlkm.lang_key IN (${placeholders})
company_code: true, AND mlkm.company_code IN ($3, '*')
}, AND mlkm.is_active = $2
}, ORDER BY mlkm.company_code ASC`,
}, [params.userLang, "Y", params.companyCode, ...params.langKeys]
orderBy: { );
multi_lang_key_master: {
company_code: "asc", // 회사별 우선, '*' 는 기본값
},
},
});
const result: Record<string, string> = {}; const result: Record<string, string> = {};
@ -726,7 +801,7 @@ export class MultiLangService {
// 실제 번역으로 덮어쓰기 (회사별 우선) // 실제 번역으로 덮어쓰기 (회사별 우선)
translations.forEach((translation) => { translations.forEach((translation) => {
const langKey = translation.multi_lang_key_master.lang_key; const langKey = translation.lang_key;
if (params.langKeys.includes(langKey)) { if (params.langKeys.includes(langKey)) {
result[langKey] = translation.lang_text; result[langKey] = translation.lang_text;
} }
@ -755,29 +830,31 @@ export class MultiLangService {
logger.info("언어 삭제 시작", { langCode }); logger.info("언어 삭제 시작", { langCode });
// 기존 언어 확인 // 기존 언어 확인
const existingLanguage = await prisma.language_master.findUnique({ const existingLanguage = await queryOne<{ lang_code: string }>(
where: { lang_code: langCode }, `SELECT lang_code FROM language_master WHERE lang_code = $1`,
}); [langCode]
);
if (!existingLanguage) { if (!existingLanguage) {
throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); throw new Error(`언어를 찾을 수 없습니다: ${langCode}`);
} }
// 트랜잭션으로 언어와 관련 텍스트 삭제 // 트랜잭션으로 언어와 관련 텍스트 삭제
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 해당 언어의 다국어 텍스트 삭제 // 해당 언어의 다국어 텍스트 삭제
const deleteResult = await tx.multi_lang_text.deleteMany({ const deleteResult = await client.query(
where: { lang_code: langCode }, `DELETE FROM multi_lang_text WHERE lang_code = $1`,
}); [langCode]
);
logger.info(`삭제된 다국어 텍스트 수: ${deleteResult.count}`, { logger.info(`삭제된 다국어 텍스트 수: ${deleteResult.rowCount}`, {
langCode, langCode,
}); });
// 언어 마스터 삭제 // 언어 마스터 삭제
await tx.language_master.delete({ await client.query(`DELETE FROM language_master WHERE lang_code = $1`, [
where: { lang_code: langCode }, langCode,
}); ]);
}); });
logger.info("언어 삭제 완료", { langCode }); logger.info("언어 삭제 완료", { langCode });

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import prisma from "../config/database"; import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache"; import { cache, CacheKeys } from "../utils/cache";
import { import {
@ -28,13 +28,14 @@ export class TableManagementService {
): Promise<{ isCodeType: boolean; codeCategory?: string }> { ): Promise<{ isCodeType: boolean; codeCategory?: string }> {
try { try {
// column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인 // column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인
const result = await prisma.$queryRaw` const result = await query(
SELECT web_type, code_category `SELECT web_type, code_category
FROM column_labels FROM column_labels
WHERE table_name = ${tableName} WHERE table_name = $1
AND column_name = ${columnName} AND column_name = $2
AND web_type = 'code' AND web_type = 'code'`,
`; [tableName, columnName]
);
if (Array.isArray(result) && result.length > 0) { if (Array.isArray(result) && result.length > 0) {
const row = result[0] as any; const row = result[0] as any;
@ -70,8 +71,8 @@ export class TableManagementService {
} }
// information_schema는 여전히 $queryRaw 사용 // information_schema는 여전히 $queryRaw 사용
const rawTables = await prisma.$queryRaw<any[]>` const rawTables = await query<any>(
SELECT `SELECT
t.table_name as "tableName", t.table_name as "tableName",
COALESCE(tl.table_label, t.table_name) as "displayName", COALESCE(tl.table_label, t.table_name) as "displayName",
COALESCE(tl.description, '') as "description", COALESCE(tl.description, '') as "description",
@ -83,8 +84,8 @@ export class TableManagementService {
AND t.table_type = 'BASE TABLE' AND t.table_type = 'BASE TABLE'
AND t.table_name NOT LIKE 'pg_%' AND t.table_name NOT LIKE 'pg_%'
AND t.table_name NOT LIKE 'sql_%' AND t.table_name NOT LIKE 'sql_%'
ORDER BY t.table_name ORDER BY t.table_name`
`; );
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const tables: TableInfo[] = rawTables.map((table) => ({ const tables: TableInfo[] = rawTables.map((table) => ({
@ -147,11 +148,12 @@ export class TableManagementService {
// 전체 컬럼 수 조회 (캐시 확인) // 전체 컬럼 수 조회 (캐시 확인)
let total = cache.get<number>(countCacheKey); let total = cache.get<number>(countCacheKey);
if (!total) { if (!total) {
const totalResult = await prisma.$queryRaw<[{ count: bigint }]>` const totalResult = await query<{ count: bigint }>(
SELECT COUNT(*) as count `SELECT COUNT(*) as count
FROM information_schema.columns c FROM information_schema.columns c
WHERE c.table_name = ${tableName} WHERE c.table_name = $1`,
`; [tableName]
);
total = Number(totalResult[0].count); total = Number(totalResult[0].count);
// 컬럼 수는 자주 변하지 않으므로 30분 캐시 // 컬럼 수는 자주 변하지 않으므로 30분 캐시
cache.set(countCacheKey, total, 30 * 60 * 1000); cache.set(countCacheKey, total, 30 * 60 * 1000);
@ -159,8 +161,8 @@ export class TableManagementService {
// 페이지네이션 적용한 컬럼 조회 // 페이지네이션 적용한 컬럼 조회
const offset = (page - 1) * size; const offset = (page - 1) * size;
const rawColumns = await prisma.$queryRaw<any[]>` const rawColumns = await query<any>(
SELECT `SELECT
c.column_name as "columnName", c.column_name as "columnName",
COALESCE(cl.column_label, c.column_name) as "displayName", COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType", c.data_type as "dataType",
@ -195,12 +197,13 @@ export class TableManagementService {
ON tc.constraint_name = kcu.constraint_name ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY' WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_name = ${tableName} AND tc.table_name = $1
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
WHERE c.table_name = ${tableName} WHERE c.table_name = $1
ORDER BY c.ordinal_position ORDER BY c.ordinal_position
LIMIT ${size} OFFSET ${offset} LIMIT $2 OFFSET $3`,
`; [tableName, size, offset]
);
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({ const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
@ -251,15 +254,12 @@ export class TableManagementService {
try { try {
logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`); logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`);
await prisma.table_labels.upsert({ await query(
where: { table_name: tableName }, `INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
update: {}, // 이미 존재하면 변경하지 않음 VALUES ($1, $2, $3, NOW(), NOW())
create: { ON CONFLICT (table_name) DO NOTHING`,
table_name: tableName, [tableName, tableName, ""]
table_label: tableName, );
description: "",
},
});
logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`); logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`);
} catch (error) { } catch (error) {
@ -282,15 +282,16 @@ export class TableManagementService {
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`); logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
// table_labels 테이블에 UPSERT // table_labels 테이블에 UPSERT
await prisma.$executeRaw` await query(
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) `INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES (${tableName}, ${displayName}, ${description || ""}, NOW(), NOW()) VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (table_name) ON CONFLICT (table_name)
DO UPDATE SET DO UPDATE SET
table_label = EXCLUDED.table_label, table_label = EXCLUDED.table_label,
description = EXCLUDED.description, description = EXCLUDED.description,
updated_date = NOW() updated_date = NOW()`,
`; [tableName, displayName, description || ""]
);
// 캐시 무효화 // 캐시 무효화
cache.delete(CacheKeys.TABLE_LIST); cache.delete(CacheKeys.TABLE_LIST);
@ -320,43 +321,40 @@ export class TableManagementService {
await this.insertTableIfNotExists(tableName); await this.insertTableIfNotExists(tableName);
// column_labels 업데이트 또는 생성 // column_labels 업데이트 또는 생성
await prisma.column_labels.upsert({ await query(
where: { `INSERT INTO column_labels (
table_name_column_name: { table_name, column_name, column_label, input_type, detail_settings,
table_name: tableName, code_category, code_value, reference_table, reference_column,
column_name: columnName, display_column, display_order, is_visible, created_date, updated_date
}, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
}, ON CONFLICT (table_name, column_name)
update: { DO UPDATE SET
column_label: settings.columnLabel, column_label = EXCLUDED.column_label,
input_type: settings.inputType, input_type = EXCLUDED.input_type,
detail_settings: settings.detailSettings, detail_settings = EXCLUDED.detail_settings,
code_category: settings.codeCategory, code_category = EXCLUDED.code_category,
code_value: settings.codeValue, code_value = EXCLUDED.code_value,
reference_table: settings.referenceTable, reference_table = EXCLUDED.reference_table,
reference_column: settings.referenceColumn, reference_column = EXCLUDED.reference_column,
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명 display_column = EXCLUDED.display_column,
display_order: settings.displayOrder || 0, display_order = EXCLUDED.display_order,
is_visible: is_visible = EXCLUDED.is_visible,
updated_date = NOW()`,
[
tableName,
columnName,
settings.columnLabel,
settings.inputType,
settings.detailSettings,
settings.codeCategory,
settings.codeValue,
settings.referenceTable,
settings.referenceColumn,
settings.displayColumn,
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true, settings.isVisible !== undefined ? settings.isVisible : true,
updated_date: new Date(), ]
}, );
create: {
table_name: tableName,
column_name: columnName,
column_label: settings.columnLabel,
input_type: settings.inputType,
detail_settings: settings.detailSettings,
code_category: settings.codeCategory,
code_value: settings.codeValue,
reference_table: settings.referenceTable,
reference_column: settings.referenceColumn,
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명
display_order: settings.displayOrder || 0,
is_visible:
settings.isVisible !== undefined ? settings.isVisible : true,
},
});
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`); cache.deleteByPattern(`table_columns:${tableName}:`);
@ -387,8 +385,8 @@ export class TableManagementService {
`전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}` `전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}`
); );
// Prisma 트랜잭션 사용 // Raw Query 트랜잭션 사용
await prisma.$transaction(async (tx) => { await transaction(async (client) => {
// 테이블이 table_labels에 없으면 자동 추가 // 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName); await this.insertTableIfNotExists(tableName);
@ -434,16 +432,18 @@ export class TableManagementService {
try { try {
logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`); logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`);
const tableLabel = await prisma.table_labels.findUnique({ const tableLabel = await queryOne<{
where: { table_name: tableName }, table_name: string;
select: { table_label: string | null;
table_name: true, description: string | null;
table_label: true, created_date: Date | null;
description: true, updated_date: Date | null;
created_date: true, }>(
updated_date: true, `SELECT table_name, table_label, description, created_date, updated_date
}, FROM table_labels
}); WHERE table_name = $1`,
[tableName]
);
if (!tableLabel) { if (!tableLabel) {
return null; return null;
@ -478,31 +478,30 @@ export class TableManagementService {
try { try {
logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`); logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`);
const columnLabel = await prisma.column_labels.findUnique({ const columnLabel = await queryOne<{
where: { id: number;
table_name_column_name: { table_name: string;
table_name: tableName, column_name: string;
column_name: columnName, column_label: string | null;
}, web_type: string | null;
}, detail_settings: any;
select: { description: string | null;
id: true, display_order: number | null;
table_name: true, is_visible: boolean | null;
column_name: true, code_category: string | null;
column_label: true, code_value: string | null;
web_type: true, reference_table: string | null;
detail_settings: true, reference_column: string | null;
description: true, created_date: Date | null;
display_order: true, updated_date: Date | null;
is_visible: true, }>(
code_category: true, `SELECT id, table_name, column_name, column_label, web_type, detail_settings,
code_value: true, description, display_order, is_visible, code_category, code_value,
reference_table: true, reference_table, reference_column, created_date, updated_date
reference_column: true, FROM column_labels
created_date: true, WHERE table_name = $1 AND column_name = $2`,
updated_date: true, [tableName, columnName]
}, );
});
if (!columnLabel) { if (!columnLabel) {
return null; return null;
@ -563,57 +562,28 @@ export class TableManagementService {
...detailSettings, ...detailSettings,
}; };
// column_labels 테이블에 해당 컬럼이 있는지 확인 // column_labels UPSERT로 업데이트 또는 생성
const existingColumn = await prisma.column_labels.findFirst({ await query(
where: { `INSERT INTO column_labels (
table_name: tableName, table_name, column_name, web_type, detail_settings, input_type, created_date, updated_date
column_name: columnName, ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
}, ON CONFLICT (table_name, column_name)
}); DO UPDATE SET
web_type = EXCLUDED.web_type,
if (existingColumn) { detail_settings = EXCLUDED.detail_settings,
// 기존 컬럼 라벨 업데이트 input_type = COALESCE(EXCLUDED.input_type, column_labels.input_type),
const updateData: any = { updated_date = NOW()`,
web_type: webType, [
detail_settings: JSON.stringify(finalDetailSettings), tableName,
updated_date: new Date(), columnName,
}; webType,
JSON.stringify(finalDetailSettings),
if (inputType) { inputType || null,
updateData.input_type = inputType; ]
}
await prisma.column_labels.update({
where: {
id: existingColumn.id,
},
data: updateData,
});
logger.info(
`컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}`
); );
} else {
// 새로운 컬럼 라벨 생성
const createData: any = {
table_name: tableName,
column_name: columnName,
web_type: webType,
detail_settings: JSON.stringify(finalDetailSettings),
created_date: new Date(),
updated_date: new Date(),
};
if (inputType) {
createData.input_type = inputType;
}
await prisma.column_labels.create({
data: createData,
});
logger.info( logger.info(
`컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` `컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
); );
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`, `컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
@ -650,20 +620,18 @@ export class TableManagementService {
}; };
// table_type_columns 테이블에서 업데이트 // table_type_columns 테이블에서 업데이트
await prisma.$executeRaw` await query(
INSERT INTO table_type_columns ( `INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date is_nullable, display_order, created_date, updated_date
) VALUES ( ) VALUES ($1, $2, $3, $4, 'Y', 0, now(), now())
${tableName}, ${columnName}, ${inputType}, ${JSON.stringify(finalDetailSettings)},
'Y', 0, now(), now()
)
ON CONFLICT (table_name, column_name) ON CONFLICT (table_name, column_name)
DO UPDATE SET DO UPDATE SET
input_type = ${inputType}, input_type = EXCLUDED.input_type,
detail_settings = ${JSON.stringify(finalDetailSettings)}, detail_settings = EXCLUDED.detail_settings,
updated_date = now(); updated_date = now()`,
`; [tableName, columnName, inputType, JSON.stringify(finalDetailSettings)]
);
logger.info( logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}` `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
@ -911,27 +879,24 @@ export class TableManagementService {
); );
// 🎯 컬럼명을 doc_type으로 사용하여 파일 구분 // 🎯 컬럼명을 doc_type으로 사용하여 파일 구분
const fileInfos = await prisma.attach_file_info.findMany({ const fileInfos = await query<{
where: { objid: string;
target_objid: String(targetObjid), real_file_name: string;
doc_type: columnName, // 컬럼명으로 파일 구분 file_size: number;
status: "ACTIVE", file_ext: string;
}, file_path: string;
select: { doc_type: string;
objid: true, doc_type_name: string;
real_file_name: true, regdate: Date;
file_size: true, writer: string;
file_ext: true, }>(
file_path: true, `SELECT objid, real_file_name, file_size, file_ext, file_path,
doc_type: true, doc_type, doc_type_name, regdate, writer
doc_type_name: true, FROM attach_file_info
regdate: true, WHERE target_objid = $1 AND doc_type = $2 AND status = 'ACTIVE'
writer: true, ORDER BY regdate DESC`,
}, [String(targetObjid), columnName]
orderBy: { );
regdate: "desc",
},
});
// 파일 정보 포맷팅 // 파일 정보 포맷팅
return fileInfos.map((fileInfo) => ({ return fileInfos.map((fileInfo) => ({
@ -956,23 +921,24 @@ export class TableManagementService {
*/ */
private async getFileInfoByPath(filePath: string): Promise<any | null> { private async getFileInfoByPath(filePath: string): Promise<any | null> {
try { try {
const fileInfo = await prisma.attach_file_info.findFirst({ const fileInfo = await queryOne<{
where: { objid: string;
file_path: filePath, real_file_name: string;
status: "ACTIVE", file_size: number;
}, file_ext: string;
select: { file_path: string;
objid: true, doc_type: string;
real_file_name: true, doc_type_name: string;
file_size: true, regdate: Date;
file_ext: true, writer: string;
file_path: true, }>(
doc_type: true, `SELECT objid, real_file_name, file_size, file_ext, file_path,
doc_type_name: true, doc_type, doc_type_name, regdate, writer
regdate: true, FROM attach_file_info
writer: true, WHERE file_path = $1 AND status = 'ACTIVE'
}, LIMIT 1`,
}); [filePath]
);
if (!fileInfo) { if (!fileInfo) {
return null; return null;
@ -1000,17 +966,14 @@ export class TableManagementService {
*/ */
private async getFileTypeColumns(tableName: string): Promise<string[]> { private async getFileTypeColumns(tableName: string): Promise<string[]> {
try { try {
const fileColumns = await prisma.column_labels.findMany({ const fileColumns = await query<{ column_name: string }>(
where: { `SELECT column_name
table_name: tableName, FROM column_labels
web_type: "file", WHERE table_name = $1 AND web_type = 'file'`,
}, [tableName]
select: { );
column_name: true,
},
});
const columnNames = fileColumns.map((col: any) => col.column_name); const columnNames = fileColumns.map((col) => col.column_name);
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames); logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
return columnNames; return columnNames;
} catch (error) { } catch (error) {
@ -1379,19 +1342,19 @@ export class TableManagementService {
displayColumn?: string; displayColumn?: string;
} | null> { } | null> {
try { try {
const result = await prisma.column_labels.findFirst({ const result = await queryOne<{
where: { web_type: string | null;
table_name: tableName, code_category: string | null;
column_name: columnName, reference_table: string | null;
}, reference_column: string | null;
select: { display_column: string | null;
web_type: true, }>(
code_category: true, `SELECT web_type, code_category, reference_table, reference_column, display_column
reference_table: true, FROM column_labels
reference_column: true, WHERE table_name = $1 AND column_name = $2
display_column: true, LIMIT 1`,
}, [tableName, columnName]
}); );
if (!result) { if (!result) {
return null; return null;
@ -1535,10 +1498,7 @@ export class TableManagementService {
// 전체 개수 조회 // 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
const countResult = await prisma.$queryRawUnsafe<any[]>( const countResult = await query<any>(countQuery, ...searchValues);
countQuery,
...searchValues
);
const total = parseInt(countResult[0].count); const total = parseInt(countResult[0].count);
// 데이터 조회 // 데이터 조회
@ -1549,12 +1509,7 @@ export class TableManagementService {
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
let data = await prisma.$queryRawUnsafe<any[]>( let data = await query<any>(dataQuery, ...searchValues, size, offset);
dataQuery,
...searchValues,
size,
offset
);
// 🎯 파일 컬럼이 있으면 파일 정보 보강 // 🎯 파일 컬럼이 있으면 파일 정보 보강
if (fileColumns.length > 0) { if (fileColumns.length > 0) {
@ -1699,10 +1654,9 @@ export class TableManagementService {
ORDER BY ordinal_position ORDER BY ordinal_position
`; `;
const columnInfoResult = (await prisma.$queryRawUnsafe( const columnInfoResult = (await query(columnInfoQuery, [
columnInfoQuery, tableName,
tableName ])) as any[];
)) as any[];
const columnTypeMap = new Map<string, string>(); const columnTypeMap = new Map<string, string>();
columnInfoResult.forEach((col: any) => { columnInfoResult.forEach((col: any) => {
@ -1759,15 +1713,15 @@ export class TableManagementService {
.join(", "); .join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", ");
const query = ` const insertQuery = `
INSERT INTO "${tableName}" (${columnNames}) INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders}) VALUES (${placeholders})
`; `;
logger.info(`실행할 쿼리: ${query}`); logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values); logger.info(`쿼리 파라미터:`, values);
await prisma.$queryRawUnsafe(query, ...values); await query(insertQuery, values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}`);
} catch (error) { } catch (error) {
@ -1800,10 +1754,9 @@ export class TableManagementService {
ORDER BY c.ordinal_position ORDER BY c.ordinal_position
`; `;
const columnInfoResult = (await prisma.$queryRawUnsafe( const columnInfoResult = (await query(columnInfoQuery, [
columnInfoQuery, tableName,
tableName ])) as any[];
)) as any[];
const columnTypeMap = new Map<string, string>(); const columnTypeMap = new Map<string, string>();
const primaryKeys: string[] = []; const primaryKeys: string[] = [];
@ -1866,7 +1819,7 @@ export class TableManagementService {
} }
// UPDATE 쿼리 생성 // UPDATE 쿼리 생성
const query = ` const updateQuery = `
UPDATE "${tableName}" UPDATE "${tableName}"
SET ${setConditions.join(", ")} SET ${setConditions.join(", ")}
WHERE ${whereConditions.join(" AND ")} WHERE ${whereConditions.join(" AND ")}
@ -1874,10 +1827,10 @@ export class TableManagementService {
const allValues = [...setValues, ...whereValues]; const allValues = [...setValues, ...whereValues];
logger.info(`실행할 UPDATE 쿼리: ${query}`); logger.info(`실행할 UPDATE 쿼리: ${updateQuery}`);
logger.info(`쿼리 파라미터:`, allValues); logger.info(`쿼리 파라미터:`, allValues);
const result = await prisma.$queryRawUnsafe(query, ...allValues); const result = await query(updateQuery, allValues);
logger.info(`테이블 데이터 수정 완료: ${tableName}`, result); logger.info(`테이블 데이터 수정 완료: ${tableName}`, result);
} catch (error) { } catch (error) {
@ -1946,9 +1899,10 @@ export class TableManagementService {
ORDER BY kcu.ordinal_position ORDER BY kcu.ordinal_position
`; `;
const primaryKeys = await prisma.$queryRawUnsafe< const primaryKeys = await query<{ column_name: string }>(
{ column_name: string }[] primaryKeyQuery,
>(primaryKeyQuery, tableName); [tableName]
);
if (primaryKeys.length === 0) { if (primaryKeys.length === 0) {
// 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성 // 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성
@ -1965,7 +1919,7 @@ export class TableManagementService {
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); const result = await query(deleteQuery, values);
deletedCount += Number(result); deletedCount += Number(result);
} }
} else { } else {
@ -1987,7 +1941,7 @@ export class TableManagementService {
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await prisma.$queryRawUnsafe(deleteQuery, ...values); const result = await query(deleteQuery, values);
deletedCount += Number(result); deletedCount += Number(result);
} }
} }
@ -2269,8 +2223,8 @@ export class TableManagementService {
// 병렬 실행 // 병렬 실행
const [dataResult, countResult] = await Promise.all([ const [dataResult, countResult] = await Promise.all([
prisma.$queryRawUnsafe(dataQuery), query(dataQuery),
prisma.$queryRawUnsafe(countQuery), query(countQuery),
]); ]);
const data = Array.isArray(dataResult) ? dataResult : []; const data = Array.isArray(dataResult) ? dataResult : [];
@ -2642,17 +2596,16 @@ export class TableManagementService {
data: Array<{ column_name: string; data_type: string }>; data: Array<{ column_name: string; data_type: string }>;
}> { }> {
try { try {
const columns = await prisma.$queryRaw< const columns = await query<{
Array<{
column_name: string; column_name: string;
data_type: string; data_type: string;
}> }>(
>` `SELECT column_name, data_type
SELECT column_name, data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = ${tableName} WHERE table_name = $1
ORDER BY ordinal_position ORDER BY ordinal_position`,
`; [tableName]
);
return { data: columns }; return { data: columns };
} catch (error) { } catch (error) {
@ -2687,45 +2640,40 @@ export class TableManagementService {
try { try {
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`); logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
await prisma.column_labels.upsert({ await query(
where: { `INSERT INTO column_labels (
table_name_column_name: { table_name, column_name, column_label, web_type, detail_settings,
table_name: tableName, description, display_order, is_visible, code_category, code_value,
column_name: columnName, reference_table, reference_column, created_date, updated_date
}, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
}, ON CONFLICT (table_name, column_name)
update: { DO UPDATE SET
column_label: updates.columnLabel, column_label = EXCLUDED.column_label,
web_type: updates.webType, web_type = EXCLUDED.web_type,
detail_settings: updates.detailSettings, detail_settings = EXCLUDED.detail_settings,
description: updates.description, description = EXCLUDED.description,
display_order: updates.displayOrder, display_order = EXCLUDED.display_order,
is_visible: updates.isVisible, is_visible = EXCLUDED.is_visible,
code_category: updates.codeCategory, code_category = EXCLUDED.code_category,
code_value: updates.codeValue, code_value = EXCLUDED.code_value,
reference_table: updates.referenceTable, reference_table = EXCLUDED.reference_table,
reference_column: updates.referenceColumn, reference_column = EXCLUDED.reference_column,
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석) updated_date = NOW()`,
updated_date: new Date(), [
}, tableName,
create: { columnName,
table_name: tableName, updates.columnLabel || columnName,
column_name: columnName, updates.webType || "text",
column_label: updates.columnLabel || columnName, updates.detailSettings,
web_type: updates.webType || "text", updates.description,
detail_settings: updates.detailSettings, updates.displayOrder || 0,
description: updates.description, updates.isVisible !== false,
display_order: updates.displayOrder || 0, updates.codeCategory,
is_visible: updates.isVisible !== false, updates.codeValue,
code_category: updates.codeCategory, updates.referenceTable,
code_value: updates.codeValue, updates.referenceColumn,
reference_table: updates.referenceTable, ]
reference_column: updates.referenceColumn, );
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석)
created_date: new Date(),
updated_date: new Date(),
},
});
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`); logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) { } catch (error) {
@ -2949,8 +2897,8 @@ export class TableManagementService {
try { try {
logger.info(`테이블 스키마 정보 조회: ${tableName}`); logger.info(`테이블 스키마 정보 조회: ${tableName}`);
const rawColumns = await prisma.$queryRaw<any[]>` const rawColumns = await query<any>(
SELECT `SELECT
column_name as "columnName", column_name as "columnName",
column_name as "displayName", column_name as "displayName",
data_type as "dataType", data_type as "dataType",
@ -2963,15 +2911,16 @@ export class TableManagementService {
CASE CASE
WHEN column_name IN ( WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = ${tableName} AND constraint_name LIKE '%_pkey' WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
) THEN true ) THEN true
ELSE false ELSE false
END as "isPrimaryKey" END as "isPrimaryKey"
FROM information_schema.columns FROM information_schema.columns
WHERE table_name = ${tableName} WHERE table_name = $1
AND table_schema = 'public' AND table_schema = 'public'
ORDER BY ordinal_position ORDER BY ordinal_position`,
`; [tableName]
);
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({ const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
tableName: tableName, tableName: tableName,
@ -3012,14 +2961,15 @@ export class TableManagementService {
try { try {
logger.info(`테이블 존재 여부 확인: ${tableName}`); logger.info(`테이블 존재 여부 확인: ${tableName}`);
const result = await prisma.$queryRaw<any[]>` const result = await query<any>(
SELECT EXISTS ( `SELECT EXISTS (
SELECT 1 FROM information_schema.tables SELECT 1 FROM information_schema.tables
WHERE table_name = ${tableName} WHERE table_name = $1
AND table_schema = 'public' AND table_schema = 'public'
AND table_type = 'BASE TABLE' AND table_type = 'BASE TABLE'
) as "exists" ) as "exists"`,
`; [tableName]
);
const exists = result[0]?.exists || false; const exists = result[0]?.exists || false;
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`); logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
@ -3038,8 +2988,8 @@ export class TableManagementService {
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
// table_type_columns에서 입력타입 정보 조회 // table_type_columns에서 입력타입 정보 조회
const rawInputTypes = await prisma.$queryRaw<any[]>` const rawInputTypes = await query<any>(
SELECT `SELECT
ttc.column_name as "columnName", ttc.column_name as "columnName",
ttc.column_name as "displayName", ttc.column_name as "displayName",
COALESCE(ttc.input_type, 'text') as "inputType", COALESCE(ttc.input_type, 'text') as "inputType",
@ -3049,9 +2999,10 @@ export class TableManagementService {
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = ${tableName} WHERE ttc.table_name = $1
ORDER BY ttc.display_order, ttc.column_name ORDER BY ttc.display_order, ttc.column_name`,
`; [tableName]
);
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({ const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
tableName: tableName, tableName: tableName,
@ -3099,7 +3050,7 @@ export class TableManagementService {
logger.info("데이터베이스 연결 상태 확인"); logger.info("데이터베이스 연결 상태 확인");
// 간단한 쿼리로 연결 테스트 // 간단한 쿼리로 연결 테스트
const result = await prisma.$queryRaw<any[]>`SELECT 1 as "test"`; const result = await query<any>(`SELECT 1 as "test"`);
if (result && result.length > 0) { if (result && result.length > 0) {
logger.info("데이터베이스 연결 성공"); logger.info("데이터베이스 연결 성공");

View File

@ -0,0 +1,426 @@
/**
* AuthService Raw Query
* Phase 1.5: 인증
*/
import { AuthService } from "../services/authService";
import { query } from "../database/db";
import { EncryptUtil } from "../utils/encryptUtil";
// 테스트 데이터
const TEST_USER = {
userId: "testuser",
password: "testpass123",
hashedPassword: "", // 테스트 전에 생성
};
describe("AuthService Raw Query 전환 테스트", () => {
// 테스트 전 준비
beforeAll(async () => {
// 테스트용 비밀번호 해시 생성
TEST_USER.hashedPassword = EncryptUtil.encrypt(TEST_USER.password);
// 테스트 사용자 생성 (이미 있으면 스킵)
try {
const existing = await query(
"SELECT user_id FROM user_info WHERE user_id = $1",
[TEST_USER.userId]
);
if (existing.length === 0) {
await query(
`INSERT INTO user_info (
user_id, user_name, user_password, company_code, locale
) VALUES ($1, $2, $3, $4, $5)`,
[
TEST_USER.userId,
"테스트 사용자",
TEST_USER.hashedPassword,
"ILSHIN",
"KR",
]
);
} else {
// 비밀번호 업데이트
await query(
"UPDATE user_info SET user_password = $1 WHERE user_id = $2",
[TEST_USER.hashedPassword, TEST_USER.userId]
);
}
} catch (error) {
console.error("테스트 사용자 생성 실패:", error);
}
});
// 테스트 후 정리
afterAll(async () => {
// 테스트 사용자 삭제 (선택적)
// await query("DELETE FROM user_info WHERE user_id = $1", [TEST_USER.userId]);
});
describe("loginPwdCheck - 로그인 비밀번호 검증", () => {
test("존재하는 사용자 로그인 성공", async () => {
const result = await AuthService.loginPwdCheck(
TEST_USER.userId,
TEST_USER.password
);
expect(result.loginResult).toBe(true);
expect(result.errorReason).toBeUndefined();
});
test("존재하지 않는 사용자 로그인 실패", async () => {
const result = await AuthService.loginPwdCheck(
"nonexistent_user_12345",
"anypassword"
);
expect(result.loginResult).toBe(false);
expect(result.errorReason).toContain("존재하지 않습니다");
});
test("잘못된 비밀번호 로그인 실패", async () => {
const result = await AuthService.loginPwdCheck(
TEST_USER.userId,
"wrongpassword123"
);
expect(result.loginResult).toBe(false);
expect(result.errorReason).toContain("일치하지 않습니다");
});
test("마스터 패스워드 로그인 성공", async () => {
const result = await AuthService.loginPwdCheck(
TEST_USER.userId,
"qlalfqjsgh11"
);
expect(result.loginResult).toBe(true);
});
test("빈 사용자 ID 처리", async () => {
const result = await AuthService.loginPwdCheck("", TEST_USER.password);
expect(result.loginResult).toBe(false);
});
test("빈 비밀번호 처리", async () => {
const result = await AuthService.loginPwdCheck(TEST_USER.userId, "");
expect(result.loginResult).toBe(false);
});
});
describe("getUserInfo - 사용자 정보 조회", () => {
test("사용자 정보 조회 성공", async () => {
const userInfo = await AuthService.getUserInfo(TEST_USER.userId);
expect(userInfo).not.toBeNull();
expect(userInfo?.userId).toBe(TEST_USER.userId);
expect(userInfo?.userName).toBeDefined();
expect(userInfo?.companyCode).toBeDefined();
expect(userInfo?.locale).toBeDefined();
});
test("사용자 정보 필드 타입 확인", async () => {
const userInfo = await AuthService.getUserInfo(TEST_USER.userId);
expect(userInfo).not.toBeNull();
expect(typeof userInfo?.userId).toBe("string");
expect(typeof userInfo?.userName).toBe("string");
expect(typeof userInfo?.companyCode).toBe("string");
expect(typeof userInfo?.locale).toBe("string");
});
test("권한 정보 조회 (있는 경우)", async () => {
const userInfo = await AuthService.getUserInfo(TEST_USER.userId);
// 권한이 없으면 authName은 빈 문자열
expect(userInfo).not.toBeNull();
if (userInfo) {
expect(typeof userInfo.authName === 'string' || userInfo.authName === undefined).toBe(true);
}
});
test("존재하지 않는 사용자 조회 실패", async () => {
const userInfo = await AuthService.getUserInfo("nonexistent_user_12345");
expect(userInfo).toBeNull();
});
test("회사 정보 기본값 확인", async () => {
const userInfo = await AuthService.getUserInfo(TEST_USER.userId);
// company_code가 없으면 기본값 "ILSHIN"
expect(userInfo?.companyCode).toBeDefined();
expect(typeof userInfo?.companyCode).toBe("string");
});
});
describe("insertLoginAccessLog - 로그인 로그 기록", () => {
test("로그인 성공 로그 기록", async () => {
await expect(
AuthService.insertLoginAccessLog({
systemName: "PMS",
userId: TEST_USER.userId,
loginResult: true,
remoteAddr: "127.0.0.1",
})
).resolves.not.toThrow();
});
test("로그인 실패 로그 기록", async () => {
await expect(
AuthService.insertLoginAccessLog({
systemName: "PMS",
userId: TEST_USER.userId,
loginResult: false,
errorMessage: "비밀번호 불일치",
remoteAddr: "127.0.0.1",
})
).resolves.not.toThrow();
});
test("로그인 로그 기록 후 DB 확인", async () => {
await AuthService.insertLoginAccessLog({
systemName: "PMS",
userId: TEST_USER.userId,
loginResult: true,
remoteAddr: "127.0.0.1",
});
// 로그가 기록되었는지 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
expect(logs[0].user_id).toBe(TEST_USER.userId.toUpperCase());
// login_result는 문자열 또는 불리언일 수 있음
expect(logs[0].login_result).toBeTruthy();
});
test("로그 기록 실패해도 예외 던지지 않음", async () => {
// 잘못된 데이터로 로그 기록 시도 (에러 발생하지만 프로세스 중단 안됨)
await expect(
AuthService.insertLoginAccessLog({
systemName: "PMS",
userId: TEST_USER.userId,
loginResult: true,
remoteAddr: "127.0.0.1",
})
).resolves.not.toThrow();
});
});
describe("processLogin - 전체 로그인 프로세스", () => {
test("전체 로그인 프로세스 성공", async () => {
const result = await AuthService.processLogin(
TEST_USER.userId,
TEST_USER.password,
"127.0.0.1"
);
expect(result.success).toBe(true);
expect(result.token).toBeDefined();
expect(result.userInfo).toBeDefined();
expect(result.userInfo?.userId).toBe(TEST_USER.userId);
expect(result.errorReason).toBeUndefined();
});
test("로그인 실패 시 토큰 없음", async () => {
const result = await AuthService.processLogin(
TEST_USER.userId,
"wrongpassword",
"127.0.0.1"
);
expect(result.success).toBe(false);
expect(result.token).toBeUndefined();
expect(result.userInfo).toBeUndefined();
expect(result.errorReason).toBeDefined();
});
test("존재하지 않는 사용자 로그인 실패", async () => {
const result = await AuthService.processLogin(
"nonexistent_user",
"anypassword",
"127.0.0.1"
);
expect(result.success).toBe(false);
expect(result.errorReason).toContain("존재하지 않습니다");
});
test("JWT 토큰 형식 확인", async () => {
const result = await AuthService.processLogin(
TEST_USER.userId,
TEST_USER.password,
"127.0.0.1"
);
if (result.success && result.token) {
// JWT 토큰은 3개 파트로 구성 (header.payload.signature)
const parts = result.token.split(".");
expect(parts.length).toBe(3);
}
});
test("로그인 프로세스 로그 기록 확인", async () => {
await AuthService.processLogin(
TEST_USER.userId,
TEST_USER.password,
"127.0.0.1"
);
// 로그인 로그가 기록되었는지 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
});
});
describe("processLogout - 로그아웃 프로세스", () => {
test("로그아웃 프로세스 성공", async () => {
await expect(
AuthService.processLogout(TEST_USER.userId, "127.0.0.1")
).resolves.not.toThrow();
});
test("로그아웃 로그 기록 확인", async () => {
await AuthService.processLogout(TEST_USER.userId, "127.0.0.1");
// 로그아웃 로그가 기록되었는지 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
AND ERROR_MESSAGE = '로그아웃'
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
expect(logs[0].error_message).toBe("로그아웃");
});
});
describe("getUserInfoFromToken - 토큰으로 사용자 정보 조회", () => {
test("유효한 토큰으로 사용자 정보 조회", async () => {
// 먼저 로그인해서 토큰 획득
const loginResult = await AuthService.processLogin(
TEST_USER.userId,
TEST_USER.password,
"127.0.0.1"
);
expect(loginResult.success).toBe(true);
expect(loginResult.token).toBeDefined();
// 토큰으로 사용자 정보 조회
const userInfo = await AuthService.getUserInfoFromToken(
loginResult.token!
);
expect(userInfo).not.toBeNull();
expect(userInfo?.userId).toBe(TEST_USER.userId);
});
test("잘못된 토큰으로 조회 실패", async () => {
const userInfo = await AuthService.getUserInfoFromToken("invalid_token");
expect(userInfo).toBeNull();
});
test("만료된 토큰으로 조회 실패", async () => {
// 만료된 토큰 시뮬레이션 (실제로는 만료 시간이 필요하므로 단순히 잘못된 토큰 사용)
const expiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.expired.token";
const userInfo = await AuthService.getUserInfoFromToken(expiredToken);
expect(userInfo).toBeNull();
});
});
describe("Raw Query 전환 검증", () => {
test("Prisma import가 없는지 확인", async () => {
const fs = require("fs");
const path = require("path");
const authServicePath = path.join(
__dirname,
"../services/authService.ts"
);
const content = fs.readFileSync(authServicePath, "utf8");
// Prisma import가 없어야 함
expect(content).not.toContain('import prisma from "../config/database"');
expect(content).not.toContain("import { PrismaClient }");
expect(content).not.toContain("prisma.user_info");
expect(content).not.toContain("prisma.$executeRaw");
});
test("Raw Query import 확인", async () => {
const fs = require("fs");
const path = require("path");
const authServicePath = path.join(
__dirname,
"../services/authService.ts"
);
const content = fs.readFileSync(authServicePath, "utf8");
// Raw Query import가 있어야 함
expect(content).toContain('import { query } from "../database/db"');
});
test("모든 메서드가 Raw Query 사용 확인", async () => {
const fs = require("fs");
const path = require("path");
const authServicePath = path.join(
__dirname,
"../services/authService.ts"
);
const content = fs.readFileSync(authServicePath, "utf8");
// query() 함수 호출이 있어야 함
expect(content).toContain("await query<");
expect(content).toContain("await query(");
});
});
describe("성능 테스트", () => {
test("로그인 프로세스 성능 (응답 시간 < 1초)", async () => {
const startTime = Date.now();
await AuthService.processLogin(
TEST_USER.userId,
TEST_USER.password,
"127.0.0.1"
);
const endTime = Date.now();
const elapsedTime = endTime - startTime;
expect(elapsedTime).toBeLessThan(1000); // 1초 이내
}, 2000); // 테스트 타임아웃 2초
test("사용자 정보 조회 성능 (응답 시간 < 500ms)", async () => {
const startTime = Date.now();
await AuthService.getUserInfo(TEST_USER.userId);
const endTime = Date.now();
const elapsedTime = endTime - startTime;
expect(elapsedTime).toBeLessThan(500); // 500ms 이내
}, 1000); // 테스트 타임아웃 1초
});
});

View File

@ -0,0 +1,455 @@
/**
* Database Manager
*
* Phase 1
*/
import { query, queryOne, transaction, getPoolStatus } from "../database/db";
import { QueryBuilder } from "../utils/queryBuilder";
import { DatabaseValidator } from "../utils/databaseValidator";
describe("Database Manager Tests", () => {
describe("QueryBuilder", () => {
test("SELECT 쿼리 생성 - 기본", () => {
const { query: sql, params } = QueryBuilder.select("users", {
where: { user_id: "test_user" },
});
expect(sql).toContain("SELECT * FROM users");
expect(sql).toContain("WHERE user_id = $1");
expect(params).toEqual(["test_user"]);
});
test("SELECT 쿼리 생성 - 복잡한 조건", () => {
const { query: sql, params } = QueryBuilder.select("users", {
columns: ["user_id", "username", "email"],
where: { status: "active", role: "admin" },
orderBy: "created_at DESC",
limit: 10,
offset: 20,
});
expect(sql).toContain("SELECT user_id, username, email FROM users");
expect(sql).toContain("WHERE status = $1 AND role = $2");
expect(sql).toContain("ORDER BY created_at DESC");
expect(sql).toContain("LIMIT $3");
expect(sql).toContain("OFFSET $4");
expect(params).toEqual(["active", "admin", 10, 20]);
});
test("SELECT 쿼리 생성 - JOIN", () => {
const { query: sql, params } = QueryBuilder.select("users", {
columns: ["users.user_id", "users.username", "departments.dept_name"],
joins: [
{
type: "LEFT",
table: "departments",
on: "users.dept_id = departments.dept_id",
},
],
where: { "users.status": "active" },
});
expect(sql).toContain("LEFT JOIN departments");
expect(sql).toContain("ON users.dept_id = departments.dept_id");
expect(sql).toContain("WHERE users.status = $1");
expect(params).toEqual(["active"]);
});
test("INSERT 쿼리 생성 - RETURNING", () => {
const { query: sql, params } = QueryBuilder.insert(
"users",
{
user_id: "new_user",
username: "John Doe",
email: "john@example.com",
},
{
returning: ["id", "user_id"],
}
);
expect(sql).toContain("INSERT INTO users");
expect(sql).toContain("(user_id, username, email)");
expect(sql).toContain("VALUES ($1, $2, $3)");
expect(sql).toContain("RETURNING id, user_id");
expect(params).toEqual(["new_user", "John Doe", "john@example.com"]);
});
test("INSERT 쿼리 생성 - UPSERT", () => {
const { query: sql, params } = QueryBuilder.insert(
"users",
{
user_id: "user123",
username: "Jane",
email: "jane@example.com",
},
{
onConflict: {
columns: ["user_id"],
action: "DO UPDATE",
updateSet: ["username", "email"],
},
returning: ["*"],
}
);
expect(sql).toContain("ON CONFLICT (user_id) DO UPDATE");
expect(sql).toContain(
"SET username = EXCLUDED.username, email = EXCLUDED.email"
);
expect(sql).toContain("RETURNING *");
});
test("UPDATE 쿼리 생성", () => {
const { query: sql, params } = QueryBuilder.update(
"users",
{ username: "Updated Name", email: "updated@example.com" },
{ user_id: "user123" },
{ returning: ["*"] }
);
expect(sql).toContain("UPDATE users");
expect(sql).toContain("SET username = $1, email = $2");
expect(sql).toContain("WHERE user_id = $3");
expect(sql).toContain("RETURNING *");
expect(params).toEqual([
"Updated Name",
"updated@example.com",
"user123",
]);
});
test("DELETE 쿼리 생성", () => {
const { query: sql, params } = QueryBuilder.delete("users", {
user_id: "user_to_delete",
});
expect(sql).toContain("DELETE FROM users");
expect(sql).toContain("WHERE user_id = $1");
expect(params).toEqual(["user_to_delete"]);
});
test("COUNT 쿼리 생성", () => {
const { query: sql, params } = QueryBuilder.count("users", {
status: "active",
});
expect(sql).toContain("SELECT COUNT(*) as count FROM users");
expect(sql).toContain("WHERE status = $1");
expect(params).toEqual(["active"]);
});
});
describe("DatabaseValidator", () => {
test("테이블명 검증 - 유효한 이름", () => {
expect(DatabaseValidator.validateTableName("users")).toBe(true);
expect(DatabaseValidator.validateTableName("user_info")).toBe(true);
expect(DatabaseValidator.validateTableName("_internal_table")).toBe(true);
expect(DatabaseValidator.validateTableName("table123")).toBe(true);
});
test("테이블명 검증 - 유효하지 않은 이름", () => {
expect(DatabaseValidator.validateTableName("")).toBe(false);
expect(DatabaseValidator.validateTableName("123table")).toBe(false);
expect(DatabaseValidator.validateTableName("user-table")).toBe(false);
expect(DatabaseValidator.validateTableName("user table")).toBe(false);
expect(DatabaseValidator.validateTableName("SELECT")).toBe(false); // 예약어
expect(DatabaseValidator.validateTableName("a".repeat(64))).toBe(false); // 너무 긺
});
test("컬럼명 검증 - 유효한 이름", () => {
expect(DatabaseValidator.validateColumnName("user_id")).toBe(true);
expect(DatabaseValidator.validateColumnName("created_at")).toBe(true);
expect(DatabaseValidator.validateColumnName("is_active")).toBe(true);
});
test("컬럼명 검증 - 유효하지 않은 이름", () => {
expect(DatabaseValidator.validateColumnName("user-id")).toBe(false);
expect(DatabaseValidator.validateColumnName("user id")).toBe(false);
expect(DatabaseValidator.validateColumnName("WHERE")).toBe(false); // 예약어
});
test("데이터 타입 검증", () => {
expect(DatabaseValidator.validateDataType("VARCHAR")).toBe(true);
expect(DatabaseValidator.validateDataType("VARCHAR(255)")).toBe(true);
expect(DatabaseValidator.validateDataType("INTEGER")).toBe(true);
expect(DatabaseValidator.validateDataType("TIMESTAMP")).toBe(true);
expect(DatabaseValidator.validateDataType("JSONB")).toBe(true);
expect(DatabaseValidator.validateDataType("INTEGER[]")).toBe(true);
expect(DatabaseValidator.validateDataType("DECIMAL(10,2)")).toBe(true);
});
test("WHERE 조건 검증", () => {
expect(
DatabaseValidator.validateWhereClause({
user_id: "test",
status: "active",
})
).toBe(true);
expect(
DatabaseValidator.validateWhereClause({
"config->>type": "form", // JSON 쿼리
})
).toBe(true);
expect(
DatabaseValidator.validateWhereClause({
"invalid-column": "value",
})
).toBe(false);
});
test("페이지네이션 검증", () => {
expect(DatabaseValidator.validatePagination(1, 10)).toBe(true);
expect(DatabaseValidator.validatePagination(5, 100)).toBe(true);
expect(DatabaseValidator.validatePagination(0, 10)).toBe(false); // page < 1
expect(DatabaseValidator.validatePagination(1, 0)).toBe(false); // pageSize < 1
expect(DatabaseValidator.validatePagination(1, 2000)).toBe(false); // pageSize > 1000
});
test("ORDER BY 검증", () => {
expect(DatabaseValidator.validateOrderBy("created_at")).toBe(true);
expect(DatabaseValidator.validateOrderBy("created_at ASC")).toBe(true);
expect(DatabaseValidator.validateOrderBy("created_at DESC")).toBe(true);
expect(DatabaseValidator.validateOrderBy("created_at INVALID")).toBe(
false
);
expect(DatabaseValidator.validateOrderBy("invalid-column ASC")).toBe(
false
);
});
test("UUID 검증", () => {
expect(
DatabaseValidator.validateUUID("550e8400-e29b-41d4-a716-446655440000")
).toBe(true);
expect(DatabaseValidator.validateUUID("invalid-uuid")).toBe(false);
});
test("이메일 검증", () => {
expect(DatabaseValidator.validateEmail("test@example.com")).toBe(true);
expect(DatabaseValidator.validateEmail("user.name@domain.co.kr")).toBe(
true
);
expect(DatabaseValidator.validateEmail("invalid-email")).toBe(false);
expect(DatabaseValidator.validateEmail("test@")).toBe(false);
});
});
describe("Integration Tests (실제 DB 연결 필요)", () => {
// 실제 데이터베이스 연결이 필요한 테스트들
// DB 연결 실패 시 스킵되도록 설정
beforeAll(async () => {
// DB 연결 테스트
try {
await query("SELECT 1 as test");
console.log("✅ 데이터베이스 연결 성공 - Integration Tests 실행");
} catch (error) {
console.warn("⚠️ 데이터베이스 연결 실패 - Integration Tests 스킵");
console.warn("DB 연결 오류:", error);
}
});
test("실제 쿼리 실행 테스트", async () => {
try {
const result = await query(
"SELECT NOW() as current_time, version() as pg_version"
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("current_time");
expect(result[0]).toHaveProperty("pg_version");
expect(result[0].pg_version).toContain("PostgreSQL");
console.log("🕐 현재 시간:", result[0].current_time);
console.log("📊 PostgreSQL 버전:", result[0].pg_version);
} catch (error) {
console.error("❌ 쿼리 실행 테스트 실패:", error);
throw error;
}
});
test("파라미터화된 쿼리 테스트", async () => {
try {
const testValue = "test_value_" + Date.now();
const result = await query(
"SELECT $1 as input_value, $2 as number_value, $3 as boolean_value",
[testValue, 42, true]
);
expect(result).toHaveLength(1);
expect(result[0].input_value).toBe(testValue);
expect(parseInt(result[0].number_value)).toBe(42); // PostgreSQL은 숫자를 문자열로 반환
expect(
result[0].boolean_value === true || result[0].boolean_value === "true"
).toBe(true); // PostgreSQL boolean 처리
console.log("📝 파라미터 테스트 결과:", result[0]);
} catch (error) {
console.error("❌ 파라미터 쿼리 테스트 실패:", error);
throw error;
}
});
test("단일 행 조회 테스트", async () => {
try {
// 존재하는 데이터 조회
const result = await queryOne("SELECT 1 as value, 'exists' as status");
expect(result).not.toBeNull();
expect(result?.value).toBe(1);
expect(result?.status).toBe("exists");
// 존재하지 않는 데이터 조회
const emptyResult = await queryOne(
"SELECT * FROM (SELECT 1 as id) t WHERE id = 999"
);
expect(emptyResult).toBeNull();
console.log("🔍 단일 행 조회 결과:", result);
} catch (error) {
console.error("❌ 단일 행 조회 테스트 실패:", error);
throw error;
}
});
test("트랜잭션 테스트", async () => {
try {
const result = await transaction(async (client) => {
const res1 = await client.query(
"SELECT 1 as value, 'first' as label"
);
const res2 = await client.query(
"SELECT 2 as value, 'second' as label"
);
const res3 = await client.query("SELECT $1 as computed_value", [
res1.rows[0].value + res2.rows[0].value,
]);
return {
res1: res1.rows,
res2: res2.rows,
res3: res3.rows,
transaction_id: Math.random().toString(36).substr(2, 9),
};
});
expect(result.res1[0].value).toBe(1);
expect(result.res1[0].label).toBe("first");
expect(result.res2[0].value).toBe(2);
expect(result.res2[0].label).toBe("second");
expect(parseInt(result.res3[0].computed_value)).toBe(3); // PostgreSQL은 숫자를 문자열로 반환
expect(result.transaction_id).toBeDefined();
console.log("🔄 트랜잭션 테스트 결과:", {
first_value: result.res1[0].value,
second_value: result.res2[0].value,
computed_value: result.res3[0].computed_value,
transaction_id: result.transaction_id,
});
} catch (error) {
console.error("❌ 트랜잭션 테스트 실패:", error);
throw error;
}
});
test("트랜잭션 롤백 테스트", async () => {
try {
await expect(
transaction(async (client) => {
await client.query("SELECT 1 as value");
// 의도적으로 오류 발생
throw new Error("의도적인 롤백 테스트");
})
).rejects.toThrow("의도적인 롤백 테스트");
console.log("🔄 트랜잭션 롤백 테스트 성공");
} catch (error) {
console.error("❌ 트랜잭션 롤백 테스트 실패:", error);
throw error;
}
});
test("연결 풀 상태 확인", () => {
try {
const status = getPoolStatus();
expect(status).toHaveProperty("totalCount");
expect(status).toHaveProperty("idleCount");
expect(status).toHaveProperty("waitingCount");
expect(typeof status.totalCount).toBe("number");
expect(typeof status.idleCount).toBe("number");
expect(typeof status.waitingCount).toBe("number");
console.log("🏊‍♂️ 연결 풀 상태:", {
총_연결수: status.totalCount,
유휴_연결수: status.idleCount,
대기_연결수: status.waitingCount,
});
} catch (error) {
console.error("❌ 연결 풀 상태 확인 실패:", error);
throw error;
}
});
test("데이터베이스 메타데이터 조회", async () => {
try {
// 현재 데이터베이스 정보 조회
const dbInfo = await query(`
SELECT
current_database() as database_name,
current_user as current_user,
inet_server_addr() as server_address,
inet_server_port() as server_port
`);
expect(dbInfo).toHaveLength(1);
expect(dbInfo[0].database_name).toBeDefined();
expect(dbInfo[0].current_user).toBeDefined();
console.log("🗄️ 데이터베이스 정보:", {
데이터베이스명: dbInfo[0].database_name,
현재사용자: dbInfo[0].current_user,
서버주소: dbInfo[0].server_address,
서버포트: dbInfo[0].server_port,
});
} catch (error) {
console.error("❌ 데이터베이스 메타데이터 조회 실패:", error);
throw error;
}
});
test("테이블 존재 여부 확인", async () => {
try {
// 시스템 테이블 조회로 안전하게 테스트
const tables = await query(`
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
LIMIT 5
`);
expect(Array.isArray(tables)).toBe(true);
console.log(`📋 발견된 테이블 수: ${tables.length}`);
if (tables.length > 0) {
console.log(
"📋 테이블 목록 (최대 5개):",
tables.map((t) => t.table_name).join(", ")
);
}
} catch (error) {
console.error("❌ 테이블 존재 여부 확인 실패:", error);
throw error;
}
});
});
});
// 테스트 실행 방법:
// npm test -- database.test.ts

View File

@ -0,0 +1,18 @@
/**
* Jest
*/
// 테스트 환경 변수 설정
process.env.NODE_ENV = "test";
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
process.env.DATABASE_URL =
process.env.TEST_DATABASE_URL ||
"postgresql://postgres:ph0909!!@39.117.244.52:11132/plm";
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
process.env.PORT = "3001";
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
// 콘솔 로그 최소화 (필요시 주석 해제)
// console.log = jest.fn();
// console.warn = jest.fn();
// console.error = jest.fn();

View File

@ -0,0 +1,382 @@
/**
* AuthService
* Phase 1.5: 인증
*
* :
* 1.
* 2. API
* 3.
*/
import request from "supertest";
import app from "../../app";
import { query } from "../../database/db";
import { EncryptUtil } from "../../utils/encryptUtil";
// 테스트 데이터
const TEST_USER = {
userId: "integration_test_user",
password: "integration_test_pass_123",
userName: "통합테스트 사용자",
};
describe("인증 시스템 통합 테스트 (Auth Integration Tests)", () => {
let authToken: string;
// 테스트 전 준비: 테스트 사용자 생성
beforeAll(async () => {
const hashedPassword = EncryptUtil.encrypt(TEST_USER.password);
try {
// 기존 사용자 확인
const existing = await query(
"SELECT user_id FROM user_info WHERE user_id = $1",
[TEST_USER.userId]
);
if (existing.length === 0) {
// 새 사용자 생성
await query(
`INSERT INTO user_info (
user_id, user_name, user_password, company_code, locale
) VALUES ($1, $2, $3, $4, $5)`,
[
TEST_USER.userId,
TEST_USER.userName,
hashedPassword,
"ILSHIN",
"KR",
]
);
} else {
// 기존 사용자 비밀번호 업데이트
await query(
"UPDATE user_info SET user_password = $1, user_name = $2 WHERE user_id = $3",
[hashedPassword, TEST_USER.userName, TEST_USER.userId]
);
}
console.log(`✅ 통합 테스트 사용자 준비 완료: ${TEST_USER.userId}`);
} catch (error) {
console.error("❌ 테스트 사용자 생성 실패:", error);
throw error;
}
});
// 테스트 후 정리 (선택적)
afterAll(async () => {
// 테스트 사용자 삭제 (필요시)
// await query("DELETE FROM user_info WHERE user_id = $1", [TEST_USER.userId]);
console.log("✅ 통합 테스트 완료");
});
describe("1. 로그인 플로우 (POST /api/auth/login)", () => {
test("✅ 올바른 자격증명으로 로그인 성공", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: TEST_USER.userId,
password: TEST_USER.password,
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.token).toBeDefined();
expect(response.body.userInfo).toBeDefined();
expect(response.body.userInfo.userId).toBe(TEST_USER.userId);
expect(response.body.userInfo.userName).toBe(TEST_USER.userName);
// 토큰 저장 (다음 테스트에서 사용)
authToken = response.body.token;
});
test("❌ 잘못된 비밀번호로 로그인 실패", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: TEST_USER.userId,
password: "wrong_password_123",
})
.expect(200);
expect(response.body.success).toBe(false);
expect(response.body.token).toBeUndefined();
expect(response.body.errorReason).toBeDefined();
expect(response.body.errorReason).toContain("일치하지 않습니다");
});
test("❌ 존재하지 않는 사용자 로그인 실패", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: "nonexistent_user_999",
password: "anypassword",
})
.expect(200);
expect(response.body.success).toBe(false);
expect(response.body.token).toBeUndefined();
expect(response.body.errorReason).toContain("존재하지 않습니다");
});
test("❌ 필수 필드 누락 시 로그인 실패", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: TEST_USER.userId,
// password 누락
})
.expect(400);
expect(response.body.success).toBe(false);
});
test("✅ JWT 토큰 형식 검증", () => {
expect(authToken).toBeDefined();
expect(typeof authToken).toBe("string");
// JWT는 3개 파트로 구성 (header.payload.signature)
const parts = authToken.split(".");
expect(parts.length).toBe(3);
});
});
describe("2. 토큰 검증 플로우 (GET /api/auth/verify)", () => {
test("✅ 유효한 토큰으로 검증 성공", async () => {
const response = await request(app)
.get("/api/auth/verify")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(response.body.valid).toBe(true);
expect(response.body.userInfo).toBeDefined();
expect(response.body.userInfo.userId).toBe(TEST_USER.userId);
});
test("❌ 토큰 없이 요청 시 실패", async () => {
const response = await request(app).get("/api/auth/verify").expect(401);
expect(response.body.valid).toBe(false);
});
test("❌ 잘못된 토큰으로 요청 시 실패", async () => {
const response = await request(app)
.get("/api/auth/verify")
.set("Authorization", "Bearer invalid_token_12345")
.expect(401);
expect(response.body.valid).toBe(false);
});
test("❌ Bearer 없는 토큰으로 요청 시 실패", async () => {
const response = await request(app)
.get("/api/auth/verify")
.set("Authorization", authToken) // Bearer 키워드 없음
.expect(401);
expect(response.body.valid).toBe(false);
});
});
describe("3. 인증된 API 요청 플로우", () => {
test("✅ 인증된 사용자로 메뉴 조회", async () => {
const response = await request(app)
.get("/api/admin/menu")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
test("❌ 인증 없이 보호된 API 요청 실패", async () => {
const response = await request(app).get("/api/admin/menu").expect(401);
expect(response.body.success).toBe(false);
});
});
describe("4. 로그아웃 플로우 (POST /api/auth/logout)", () => {
test("✅ 로그아웃 성공", async () => {
const response = await request(app)
.post("/api/auth/logout")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
});
test("✅ 로그아웃 로그 기록 확인", async () => {
// 로그아웃 로그가 기록되었는지 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
AND ERROR_MESSAGE = '로그아웃'
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
expect(logs[0].error_message).toBe("로그아웃");
});
});
describe("5. 전체 시나리오 테스트", () => {
test("✅ 로그인 → 인증 → API 호출 → 로그아웃 전체 플로우", async () => {
// 1. 로그인
const loginResponse = await request(app)
.post("/api/auth/login")
.send({
userId: TEST_USER.userId,
password: TEST_USER.password,
})
.expect(200);
expect(loginResponse.body.success).toBe(true);
const token = loginResponse.body.token;
// 2. 토큰 검증
const verifyResponse = await request(app)
.get("/api/auth/verify")
.set("Authorization", `Bearer ${token}`)
.expect(200);
expect(verifyResponse.body.valid).toBe(true);
// 3. 보호된 API 호출
const menuResponse = await request(app)
.get("/api/admin/menu")
.set("Authorization", `Bearer ${token}`)
.expect(200);
expect(Array.isArray(menuResponse.body)).toBe(true);
// 4. 로그아웃
const logoutResponse = await request(app)
.post("/api/auth/logout")
.set("Authorization", `Bearer ${token}`)
.expect(200);
expect(logoutResponse.body.success).toBe(true);
});
});
describe("6. 에러 처리 및 예외 상황", () => {
test("❌ SQL Injection 시도 차단", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: "admin' OR '1'='1",
password: "password",
})
.expect(200);
// SQL Injection이 차단되어 로그인 실패해야 함
expect(response.body.success).toBe(false);
});
test("❌ 빈 문자열로 로그인 시도", async () => {
const response = await request(app)
.post("/api/auth/login")
.send({
userId: "",
password: "",
})
.expect(400);
expect(response.body.success).toBe(false);
});
test("❌ 매우 긴 사용자 ID로 로그인 시도", async () => {
const longUserId = "a".repeat(1000);
const response = await request(app)
.post("/api/auth/login")
.send({
userId: longUserId,
password: "password",
})
.expect(200);
expect(response.body.success).toBe(false);
});
});
describe("7. 로그인 이력 확인", () => {
test("✅ 로그인 성공 이력 조회", async () => {
// 로그인 실행
await request(app).post("/api/auth/login").send({
userId: TEST_USER.userId,
password: TEST_USER.password,
});
// 로그인 이력 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
AND LOGIN_RESULT = true
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
expect(logs[0].login_result).toBeTruthy();
expect(logs[0].system_name).toBe("PMS");
});
test("✅ 로그인 실패 이력 조회", async () => {
// 로그인 실패 실행
await request(app).post("/api/auth/login").send({
userId: TEST_USER.userId,
password: "wrong_password",
});
// 로그인 실패 이력 확인
const logs = await query(
`SELECT * FROM LOGIN_ACCESS_LOG
WHERE USER_ID = UPPER($1)
AND LOGIN_RESULT = false
AND ERROR_MESSAGE IS NOT NULL
ORDER BY LOG_TIME DESC
LIMIT 1`,
[TEST_USER.userId]
);
expect(logs.length).toBeGreaterThan(0);
expect(logs[0].login_result).toBeFalsy();
expect(logs[0].error_message).toBeDefined();
});
});
describe("8. 성능 테스트", () => {
test("✅ 동시 로그인 요청 처리 (10개)", async () => {
const promises = Array.from({ length: 10 }, () =>
request(app).post("/api/auth/login").send({
userId: TEST_USER.userId,
password: TEST_USER.password,
})
);
const responses = await Promise.all(promises);
responses.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
}, 10000); // 10초 타임아웃
test("✅ 로그인 응답 시간 (< 1초)", async () => {
const startTime = Date.now();
await request(app).post("/api/auth/login").send({
userId: TEST_USER.userId,
password: TEST_USER.password,
});
const endTime = Date.now();
const elapsedTime = endTime - startTime;
expect(elapsedTime).toBeLessThan(1000); // 1초 이내
}, 2000);
});
});

View File

@ -0,0 +1,24 @@
/**
* Jest
*/
import { closePool } from "../database/db";
// 테스트 완료 후 정리
afterAll(async () => {
// 데이터베이스 연결 풀 종료
await closePool();
});
// 테스트 타임아웃 설정
jest.setTimeout(30000);
// 전역 테스트 설정
beforeEach(() => {
// 각 테스트 전에 실행할 설정
});
afterEach(() => {
// 각 테스트 후에 실행할 정리
});

View File

@ -0,0 +1,207 @@
/**
*
*
* Raw Query
*/
/**
*
*/
export interface QueryResult<T = any> {
rows: T[];
rowCount: number | null;
command: string;
fields?: any[];
}
/**
*
*/
export enum IsolationLevel {
READ_UNCOMMITTED = 'READ UNCOMMITTED',
READ_COMMITTED = 'READ COMMITTED',
REPEATABLE_READ = 'REPEATABLE READ',
SERIALIZABLE = 'SERIALIZABLE',
}
/**
*
*/
export interface TableSchema {
tableName: string;
columns: ColumnDefinition[];
constraints?: TableConstraint[];
indexes?: IndexDefinition[];
comment?: string;
}
/**
*
*/
export interface ColumnDefinition {
name: string;
type: PostgreSQLDataType;
nullable?: boolean;
defaultValue?: string;
isPrimaryKey?: boolean;
isUnique?: boolean;
references?: ForeignKeyReference;
comment?: string;
}
/**
* PostgreSQL
*/
export type PostgreSQLDataType =
// 숫자 타입
| 'SMALLINT'
| 'INTEGER'
| 'BIGINT'
| 'DECIMAL'
| 'NUMERIC'
| 'REAL'
| 'DOUBLE PRECISION'
| 'SERIAL'
| 'BIGSERIAL'
// 문자열 타입
| 'CHARACTER VARYING' // VARCHAR
| 'VARCHAR'
| 'CHARACTER'
| 'CHAR'
| 'TEXT'
// 날짜/시간 타입
| 'TIMESTAMP'
| 'TIMESTAMP WITH TIME ZONE'
| 'TIMESTAMPTZ'
| 'DATE'
| 'TIME'
| 'TIME WITH TIME ZONE'
| 'INTERVAL'
// Boolean
| 'BOOLEAN'
// JSON
| 'JSON'
| 'JSONB'
// UUID
| 'UUID'
// 배열
| 'ARRAY'
// 기타
| 'BYTEA'
| string; // 커스텀 타입 허용
/**
*
*/
export interface ForeignKeyReference {
table: string;
column: string;
onDelete?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';
onUpdate?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';
}
/**
*
*/
export interface TableConstraint {
name: string;
type: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK';
columns: string[];
references?: ForeignKeyReference;
checkExpression?: string;
}
/**
*
*/
export interface IndexDefinition {
name: string;
columns: string[];
unique?: boolean;
type?: 'BTREE' | 'HASH' | 'GIN' | 'GIST';
where?: string; // Partial index
}
/**
*
*/
export interface QueryOptions {
timeout?: number;
preparedStatement?: boolean;
rowMode?: 'array' | 'object';
}
/**
*
*/
export interface DynamicTableRequest {
tableName: string;
columns: ColumnDefinition[];
constraints?: TableConstraint[];
indexes?: IndexDefinition[];
ifNotExists?: boolean;
comment?: string;
}
/**
*
*/
export interface AlterTableRequest {
tableName: string;
operations: AlterTableOperation[];
}
/**
*
*/
export type AlterTableOperation =
| { type: 'ADD_COLUMN'; column: ColumnDefinition }
| { type: 'DROP_COLUMN'; columnName: string }
| { type: 'ALTER_COLUMN'; columnName: string; newDefinition: Partial<ColumnDefinition> }
| { type: 'RENAME_COLUMN'; oldName: string; newName: string }
| { type: 'ADD_CONSTRAINT'; constraint: TableConstraint }
| { type: 'DROP_CONSTRAINT'; constraintName: string };
/**
*
*/
export interface PaginationRequest {
page: number;
pageSize: number;
orderBy?: string;
orderDirection?: 'ASC' | 'DESC';
}
/**
*
*/
export interface PaginationResponse<T> {
data: T[];
pagination: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
*
*/
export interface QueryStatistics {
query: string;
executionTime: number;
rowsAffected: number;
timestamp: Date;
success: boolean;
error?: string;
}

View File

@ -0,0 +1,383 @@
/**
*
*
* SQL
*/
export class DatabaseValidator {
// PostgreSQL 예약어 목록 (주요 키워드만)
private static readonly RESERVED_WORDS = new Set([
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"FROM",
"WHERE",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"FULL",
"ON",
"GROUP",
"BY",
"ORDER",
"HAVING",
"LIMIT",
"OFFSET",
"UNION",
"ALL",
"DISTINCT",
"AS",
"AND",
"OR",
"NOT",
"NULL",
"TRUE",
"FALSE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"IF",
"EXISTS",
"IN",
"BETWEEN",
"LIKE",
"ILIKE",
"SIMILAR",
"TO",
"CREATE",
"DROP",
"ALTER",
"TABLE",
"INDEX",
"VIEW",
"FUNCTION",
"PROCEDURE",
"TRIGGER",
"DATABASE",
"SCHEMA",
"USER",
"ROLE",
"GRANT",
"REVOKE",
"COMMIT",
"ROLLBACK",
"BEGIN",
"TRANSACTION",
"SAVEPOINT",
"RELEASE",
"CONSTRAINT",
"PRIMARY",
"FOREIGN",
"KEY",
"UNIQUE",
"CHECK",
"DEFAULT",
"REFERENCES",
"CASCADE",
"RESTRICT",
"SET",
"ACTION",
"DEFERRABLE",
"INITIALLY",
"DEFERRED",
"IMMEDIATE",
"MATCH",
"PARTIAL",
"SIMPLE",
"FULL",
]);
// 유효한 PostgreSQL 데이터 타입 패턴
private static readonly DATA_TYPE_PATTERNS = [
/^(SMALLINT|INTEGER|BIGINT|DECIMAL|NUMERIC|REAL|DOUBLE\s+PRECISION|SMALLSERIAL|SERIAL|BIGSERIAL)$/i,
/^(MONEY)$/i,
/^(CHARACTER\s+VARYING|VARCHAR|CHARACTER|CHAR|TEXT)(\(\d+\))?$/i,
/^(BYTEA)$/i,
/^(TIMESTAMP|TIME)(\s+(WITH|WITHOUT)\s+TIME\s+ZONE)?(\(\d+\))?$/i,
/^(DATE|INTERVAL)(\(\d+\))?$/i,
/^(BOOLEAN|BOOL)$/i,
/^(POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)$/i,
/^(CIDR|INET|MACADDR|MACADDR8)$/i,
/^(BIT|BIT\s+VARYING)(\(\d+\))?$/i,
/^(TSVECTOR|TSQUERY)$/i,
/^(UUID)$/i,
/^(XML)$/i,
/^(JSON|JSONB)$/i,
/^(ARRAY|INTEGER\[\]|TEXT\[\]|VARCHAR\[\])$/i,
/^(DECIMAL|NUMERIC)\(\d+,\d+\)$/i,
];
/**
*
*/
static validateTableName(tableName: string): boolean {
if (!tableName || typeof tableName !== "string") {
return false;
}
// 길이 제한 (PostgreSQL 최대 63자)
if (tableName.length === 0 || tableName.length > 63) {
return false;
}
// 유효한 식별자 패턴 (문자 또는 밑줄로 시작, 문자/숫자/밑줄만 포함)
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(tableName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(tableName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateColumnName(columnName: string): boolean {
if (!columnName || typeof columnName !== "string") {
return false;
}
// 길이 제한
if (columnName.length === 0 || columnName.length > 63) {
return false;
}
// JSON 연산자 포함 컬럼명 허용 (예: config->>'type', data->>path)
if (columnName.includes("->") || columnName.includes("->>")) {
const baseName = columnName.split(/->|->>/)[0];
return this.validateColumnName(baseName);
}
// 유효한 식별자 패턴
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(columnName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(columnName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateDataType(dataType: string): boolean {
if (!dataType || typeof dataType !== "string") {
return false;
}
const normalizedType = dataType.trim().toUpperCase();
return this.DATA_TYPE_PATTERNS.some((pattern) =>
pattern.test(normalizedType)
);
}
/**
* WHERE
*/
static validateWhereClause(whereClause: Record<string, any>): boolean {
if (!whereClause || typeof whereClause !== "object") {
return false;
}
// 모든 키가 유효한 컬럼명인지 확인
for (const key of Object.keys(whereClause)) {
if (!this.validateColumnName(key)) {
return false;
}
}
return true;
}
/**
*
*/
static validatePagination(page: number, pageSize: number): boolean {
// 페이지 번호는 1 이상
if (!Number.isInteger(page) || page < 1) {
return false;
}
// 페이지 크기는 1 이상 1000 이하
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) {
return false;
}
return true;
}
/**
* ORDER BY
*/
static validateOrderBy(orderBy: string): boolean {
if (!orderBy || typeof orderBy !== "string") {
return false;
}
// 기본 패턴: column_name [ASC|DESC]
const orderPattern = /^[a-zA-Z_][a-zA-Z0-9_]*(\s+(ASC|DESC))?$/i;
// 여러 컬럼 정렬의 경우 콤마로 분리하여 각각 검증
const orderClauses = orderBy.split(",").map((clause) => clause.trim());
return orderClauses.every((clause) => {
return (
orderPattern.test(clause) &&
this.validateColumnName(clause.split(/\s+/)[0])
);
});
}
/**
* UUID
*/
static validateUUID(uuid: string): boolean {
if (!uuid || typeof uuid !== "string") {
return false;
}
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidPattern.test(uuid);
}
/**
*
*/
static validateEmail(email: string): boolean {
if (!email || typeof email !== "string") {
return false;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email) && email.length <= 254;
}
/**
* SQL
*/
static containsSqlInjection(input: string): boolean {
if (!input || typeof input !== "string") {
return false;
}
// 위험한 SQL 패턴들
const dangerousPatterns = [
/('|\\')|(;)|(--)|(\s+(OR|AND)\s+\d+\s*=\s*\d+)/i,
/(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE)/i,
/(\bxp_\w+|\bsp_\w+)/i, // SQL Server 확장 프로시저
/(script|javascript|vbscript|onload|onerror)/i, // XSS 패턴
];
return dangerousPatterns.some((pattern) => pattern.test(input));
}
/**
*
*/
static validateNumberRange(
value: number,
min?: number,
max?: number
): boolean {
if (typeof value !== "number" || !Number.isFinite(value)) {
return false;
}
if (min !== undefined && value < min) {
return false;
}
if (max !== undefined && value > max) {
return false;
}
return true;
}
/**
*
*/
static validateStringLength(
value: string,
minLength?: number,
maxLength?: number
): boolean {
if (typeof value !== "string") {
return false;
}
if (minLength !== undefined && value.length < minLength) {
return false;
}
if (maxLength !== undefined && value.length > maxLength) {
return false;
}
return true;
}
/**
* JSON
*/
static validateJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString);
return true;
} catch {
return false;
}
}
/**
* (ISO 8601)
*/
static validateDateISO(dateString: string): boolean {
if (!dateString || typeof dateString !== "string") {
return false;
}
const date = new Date(dateString);
return !isNaN(date.getTime()) && dateString === date.toISOString();
}
/**
*
*/
static validateArray<T>(
array: any[],
validator: (item: T) => boolean,
minLength?: number,
maxLength?: number
): boolean {
if (!Array.isArray(array)) {
return false;
}
if (minLength !== undefined && array.length < minLength) {
return false;
}
if (maxLength !== undefined && array.length > maxLength) {
return false;
}
return array.every((item) => validator(item));
}
}

View File

@ -0,0 +1,287 @@
/**
* SQL
*
* Raw Query
*/
export interface SelectOptions {
columns?: string[];
where?: Record<string, any>;
joins?: JoinClause[];
orderBy?: string;
limit?: number;
offset?: number;
groupBy?: string[];
having?: Record<string, any>;
}
export interface JoinClause {
type: 'INNER' | 'LEFT' | 'RIGHT' | 'FULL';
table: string;
on: string;
}
export interface InsertOptions {
returning?: string[];
onConflict?: {
columns: string[];
action: 'DO NOTHING' | 'DO UPDATE';
updateSet?: string[];
};
}
export interface UpdateOptions {
returning?: string[];
}
export interface QueryResult {
query: string;
params: any[];
}
export class QueryBuilder {
/**
* SELECT
*/
static select(table: string, options: SelectOptions = {}): QueryResult {
const {
columns = ['*'],
where = {},
joins = [],
orderBy,
limit,
offset,
groupBy = [],
having = {},
} = options;
let query = `SELECT ${columns.join(', ')} FROM ${table}`;
const params: any[] = [];
let paramIndex = 1;
// JOIN 절 추가
for (const join of joins) {
query += ` ${join.type} JOIN ${join.table} ON ${join.on}`;
}
// WHERE 절 추가
const whereConditions = Object.keys(where);
if (whereConditions.length > 0) {
const whereClause = whereConditions
.map((key) => {
params.push(where[key]);
return `${key} = $${paramIndex++}`;
})
.join(' AND ');
query += ` WHERE ${whereClause}`;
}
// GROUP BY 절 추가
if (groupBy.length > 0) {
query += ` GROUP BY ${groupBy.join(', ')}`;
}
// HAVING 절 추가
const havingConditions = Object.keys(having);
if (havingConditions.length > 0) {
const havingClause = havingConditions
.map((key) => {
params.push(having[key]);
return `${key} = $${paramIndex++}`;
})
.join(' AND ');
query += ` HAVING ${havingClause}`;
}
// ORDER BY 절 추가
if (orderBy) {
query += ` ORDER BY ${orderBy}`;
}
// LIMIT 절 추가
if (limit !== undefined) {
params.push(limit);
query += ` LIMIT $${paramIndex++}`;
}
// OFFSET 절 추가
if (offset !== undefined) {
params.push(offset);
query += ` OFFSET $${paramIndex++}`;
}
return { query, params };
}
/**
* INSERT
*/
static insert(
table: string,
data: Record<string, any>,
options: InsertOptions = {}
): QueryResult {
const { returning = [], onConflict } = options;
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
let query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
// ON CONFLICT 절 추가
if (onConflict) {
query += ` ON CONFLICT (${onConflict.columns.join(', ')})`;
if (onConflict.action === 'DO NOTHING') {
query += ' DO NOTHING';
} else if (onConflict.action === 'DO UPDATE' && onConflict.updateSet) {
const updateSet = onConflict.updateSet
.map(col => `${col} = EXCLUDED.${col}`)
.join(', ');
query += ` DO UPDATE SET ${updateSet}`;
}
}
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(', ')}`;
}
return { query, params: values };
}
/**
* UPDATE
*/
static update(
table: string,
data: Record<string, any>,
where: Record<string, any>,
options: UpdateOptions = {}
): QueryResult {
const { returning = [] } = options;
const dataKeys = Object.keys(data);
const dataValues = Object.values(data);
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let paramIndex = 1;
// SET 절 생성
const setClause = dataKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(', ');
// WHERE 절 생성
const whereClause = whereKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(' AND ');
let query = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`;
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(', ')}`;
}
const params = [...dataValues, ...whereValues];
return { query, params };
}
/**
* DELETE
*/
static delete(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(' AND ');
const query = `DELETE FROM ${table} WHERE ${whereClause}`;
return { query, params: whereValues };
}
/**
* COUNT
*/
static count(table: string, where: Record<string, any> = {}): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let query = `SELECT COUNT(*) as count FROM ${table}`;
if (whereKeys.length > 0) {
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(' AND ');
query += ` WHERE ${whereClause}`;
}
return { query, params: whereValues };
}
/**
* EXISTS
*/
static exists(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(' AND ');
const query = `SELECT EXISTS(SELECT 1 FROM ${table} WHERE ${whereClause}) as exists`;
return { query, params: whereValues };
}
/**
* WHERE ( )
*/
static buildWhereClause(
conditions: Record<string, any>,
startParamIndex: number = 1
): { clause: string; params: any[]; nextParamIndex: number } {
const keys = Object.keys(conditions);
const params: any[] = [];
let paramIndex = startParamIndex;
if (keys.length === 0) {
return { clause: '', params: [], nextParamIndex: paramIndex };
}
const clause = keys
.map((key) => {
const value = conditions[key];
// 특수 연산자 처리
if (key.includes('>>') || key.includes('->')) {
// JSON 쿼리
params.push(value);
return `${key} = $${paramIndex++}`;
} else if (Array.isArray(value)) {
// IN 절
const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
params.push(...value);
return `${key} IN (${placeholders})`;
} else if (value === null) {
// NULL 체크
return `${key} IS NULL`;
} else {
// 일반 조건
params.push(value);
return `${key} = $${paramIndex++}`;
}
})
.join(' AND ');
return { clause, params, nextParamIndex: paramIndex };
}
}

View File

@ -76,8 +76,8 @@ export default function TableManagementPage() {
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 최고 관리자 여부 확인 // 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin"; const isSuperAdmin = user?.companyCode === "*";
// 다국어 텍스트 로드 // 다국어 텍스트 로드
useEffect(() => { useEffect(() => {
@ -541,9 +541,9 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return ( return (
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6"> <div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
@ -593,7 +593,7 @@ export default function TableManagementPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 테이블 목록 */} {/* 테이블 목록 */}
<Card className="lg:col-span-1 shadow-sm"> <Card className="shadow-sm lg:col-span-1">
<CardHeader className="bg-gray-50/50"> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-gray-600" /> <Database className="h-5 w-5 text-gray-600" />
@ -663,7 +663,7 @@ export default function TableManagementPage() {
</Card> </Card>
{/* 컬럼 타입 관리 */} {/* 컬럼 타입 관리 */}
<Card className="lg:col-span-4 shadow-sm"> <Card className="shadow-sm lg:col-span-4">
<CardHeader className="bg-gray-50/50"> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" /> <Settings className="h-5 w-5 text-gray-600" />

View File

@ -14,7 +14,7 @@ import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories"; import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
import type { CodeCategory } from "@/types/commonCode"; import type { CodeCategory } from "@/types/commonCode";
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation"; import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
import { useFormValidation } from "@/hooks/useFormValidation"; import { useState } from "react";
import { import {
createCategorySchema, createCategorySchema,
updateCategorySchema, updateCategorySchema,
@ -41,45 +41,6 @@ export function CodeCategoryFormModal({
const isEditing = !!editingCategoryCode; const isEditing = !!editingCategoryCode;
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode); const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
// 검증 상태 관리
const formValidation = useFormValidation({
fields: ["categoryCode", "categoryName", "categoryNameEng", "description"],
});
// 중복 검사 훅들
const categoryCodeCheck = useCheckCategoryDuplicate(
"categoryCode",
formValidation.getFieldValue("categoryCode"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryCode"),
);
const categoryNameCheck = useCheckCategoryDuplicate(
"categoryName",
formValidation.getFieldValue("categoryName"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryName"),
);
const categoryNameEngCheck = useCheckCategoryDuplicate(
"categoryNameEng",
formValidation.getFieldValue("categoryNameEng"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryNameEng"),
);
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
const hasDuplicateErrors =
(!isEditing && categoryCodeCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryCode")) ||
(categoryNameCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryName")) ||
(categoryNameEngCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryNameEng"));
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
const isDuplicateChecking =
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
// 필수 필드들이 모두 검증되었는지 확인 (생성 시에만 적용)
// 생성과 수정을 위한 별도 폼 설정 // 생성과 수정을 위한 별도 폼 설정
const createForm = useForm<CreateCategoryData>({ const createForm = useForm<CreateCategoryData>({
resolver: zodResolver(createCategorySchema), resolver: zodResolver(createCategorySchema),
@ -105,11 +66,54 @@ export function CodeCategoryFormModal({
}, },
}); });
// 필드 검증 상태 관리
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
// 필드 검증 처리 함수
const handleFieldBlur = (fieldName: string) => {
setValidatedFields((prev) => new Set(prev).add(fieldName));
};
// 중복 검사 훅들
const categoryCodeCheck = useCheckCategoryDuplicate(
"categoryCode",
isEditing ? "" : createForm.watch("categoryCode"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryCode"),
);
const categoryNameCheck = useCheckCategoryDuplicate(
"categoryName",
isEditing ? updateForm.watch("categoryName") : createForm.watch("categoryName"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryName"),
);
const categoryNameEngCheck = useCheckCategoryDuplicate(
"categoryNameEng",
isEditing ? updateForm.watch("categoryNameEng") : createForm.watch("categoryNameEng"),
isEditing ? editingCategoryCode : undefined,
validatedFields.has("categoryNameEng"),
);
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
const hasDuplicateErrors =
(!isEditing && categoryCodeCheck.data?.isDuplicate && validatedFields.has("categoryCode")) ||
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName")) ||
(categoryNameEngCheck.data?.isDuplicate && validatedFields.has("categoryNameEng"));
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
const isDuplicateChecking =
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
// 폼은 조건부로 직접 사용 // 폼은 조건부로 직접 사용
// 편집 모드일 때 기존 데이터 로드 // 편집 모드일 때 기존 데이터 로드
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// 검증 상태 초기화
setValidatedFields(new Set());
if (isEditing && editingCategory) { if (isEditing && editingCategory) {
// 수정 모드: 기존 데이터 로드 // 수정 모드: 기존 데이터 로드
updateForm.reset({ updateForm.reset({
@ -132,7 +136,7 @@ export function CodeCategoryFormModal({
}); });
} }
} }
}, [isOpen, isEditing, editingCategory, categories]); }, [isOpen, isEditing, editingCategory, categories, createForm, updateForm]);
const handleSubmit = isEditing const handleSubmit = isEditing
? updateForm.handleSubmit(async (data) => { ? updateForm.handleSubmit(async (data) => {
@ -177,7 +181,7 @@ export function CodeCategoryFormModal({
disabled={isLoading} disabled={isLoading}
placeholder="카테고리 코드를 입력하세요" placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""} className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
onBlur={formValidation.createBlurHandler("categoryCode")} onBlur={() => handleFieldBlur("categoryCode")}
/> />
{createForm.formState.errors.categoryCode && ( {createForm.formState.errors.categoryCode && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p> <p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
@ -218,7 +222,7 @@ export function CodeCategoryFormModal({
? "border-red-500" ? "border-red-500"
: "" : ""
} }
onBlur={formValidation.createBlurHandler("categoryName")} onBlur={() => handleFieldBlur("categoryName")}
/> />
{isEditing {isEditing
? updateForm.formState.errors.categoryName && ( ? updateForm.formState.errors.categoryName && (
@ -253,7 +257,7 @@ export function CodeCategoryFormModal({
? "border-red-500" ? "border-red-500"
: "" : ""
} }
onBlur={formValidation.createBlurHandler("categoryNameEng")} onBlur={() => handleFieldBlur("categoryNameEng")}
/> />
{isEditing {isEditing
? updateForm.formState.errors.categoryNameEng && ( ? updateForm.formState.errors.categoryNameEng && (
@ -291,7 +295,7 @@ export function CodeCategoryFormModal({
? "border-red-500" ? "border-red-500"
: "" : ""
} }
onBlur={formValidation.createBlurHandler("description")} onBlur={() => handleFieldBlur("description")}
/> />
{isEditing {isEditing
? updateForm.formState.errors.description && ( ? updateForm.formState.errors.description && (

View File

@ -67,7 +67,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
})), })),
}); });
}, },
getItemId: (code: CodeInfo) => code.code_value, getItemId: (code: CodeInfo) => code.codeValue || code.code_value,
}); });
// 새 코드 생성 // 새 코드 생성
@ -95,7 +95,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
try { try {
await deleteCodeMutation.mutateAsync({ await deleteCodeMutation.mutateAsync({
categoryCode, categoryCode,
codeValue: deletingCode.code_value, codeValue: deletingCode.codeValue || deletingCode.code_value,
}); });
setShowDeleteModal(false); setShowDeleteModal(false);
@ -182,13 +182,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
<div className="p-2"> <div className="p-2">
<DndContext {...dragAndDrop.dndContextProps}> <DndContext {...dragAndDrop.dndContextProps}>
<SortableContext <SortableContext
items={filteredCodes.map((code) => code.code_value)} items={filteredCodes.map((code) => code.codeValue || code.code_value)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1"> <div className="space-y-1">
{filteredCodes.map((code, index) => ( {filteredCodes.map((code, index) => (
<SortableCodeItem <SortableCodeItem
key={`${code.code_value}-${index}`} key={`${code.codeValue || code.code_value}-${index}`}
code={code} code={code}
categoryCode={categoryCode} categoryCode={categoryCode}
onEdit={() => handleEditCode(code)} onEdit={() => handleEditCode(code)}
@ -208,20 +208,28 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3> <h3 className="font-medium text-gray-900">
{activeCode.codeName || activeCode.code_name}
</h3>
<Badge <Badge
variant={activeCode.is_active === "Y" ? "default" : "secondary"} variant={
activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "default"
: "secondary"
}
className={cn( className={cn(
"transition-colors", "transition-colors",
activeCode.is_active === "Y" activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600", : "bg-gray-100 text-gray-600",
)} )}
> >
{activeCode.is_active === "Y" ? "활성" : "비활성"} {activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</div> </div>
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p> <p className="mt-1 text-sm text-gray-600">
{activeCode.codeValue || activeCode.code_value}
</p>
{activeCode.description && ( {activeCode.description && (
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p> <p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
)} )}

View File

@ -51,7 +51,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
categoryCode, categoryCode,
"codeValue", "codeValue",
validationStates.codeValue.value, validationStates.codeValue.value,
isEditing ? editingCode?.code_value : undefined, isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
validationStates.codeValue.enabled, validationStates.codeValue.enabled,
); );
@ -59,7 +59,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
categoryCode, categoryCode,
"codeName", "codeName",
validationStates.codeName.value, validationStates.codeName.value,
isEditing ? editingCode?.code_value : undefined, isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
validationStates.codeName.enabled, validationStates.codeName.enabled,
); );
@ -67,7 +67,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
categoryCode, categoryCode,
"codeNameEng", "codeNameEng",
validationStates.codeNameEng.value, validationStates.codeNameEng.value,
isEditing ? editingCode?.code_value : undefined, isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
validationStates.codeNameEng.enabled, validationStates.codeNameEng.enabled,
); );
@ -102,18 +102,18 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
if (isEditing && editingCode) { if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정) // 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
form.reset({ form.reset({
codeName: editingCode.code_name, codeName: editingCode.codeName || editingCode.code_name,
codeNameEng: editingCode.code_name_eng || "", codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "",
description: editingCode.description || "", description: editingCode.description || "",
sortOrder: editingCode.sort_order, sortOrder: editingCode.sortOrder || editingCode.sort_order,
isActive: editingCode.is_active as "Y" | "N", // 타입 캐스팅 isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", // 타입 캐스팅
}); });
// codeValue는 별도로 설정 (표시용) // codeValue는 별도로 설정 (표시용)
form.setValue("codeValue" as any, editingCode.code_value); form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value);
} else { } else {
// 새 코드 모드: 자동 순서 계산 // 새 코드 모드: 자동 순서 계산
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order)) : 0; const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0;
form.reset({ form.reset({
codeValue: "", codeValue: "",
@ -132,7 +132,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
// 수정 // 수정
await updateCodeMutation.mutateAsync({ await updateCodeMutation.mutateAsync({
categoryCode, categoryCode,
codeValue: editingCode.code_value, codeValue: editingCode.codeValue || editingCode.code_value,
data: data as UpdateCodeData, data: data as UpdateCodeData,
}); });
} else { } else {

View File

@ -26,7 +26,7 @@ export function SortableCodeItem({
isDragOverlay = false, isDragOverlay = false,
}: SortableCodeItemProps) { }: SortableCodeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: code.code_value, id: code.codeValue || code.code_value,
disabled: isDragOverlay, disabled: isDragOverlay,
}); });
const updateCodeMutation = useUpdateCode(); const updateCodeMutation = useUpdateCode();
@ -39,14 +39,20 @@ export function SortableCodeItem({
// 활성/비활성 토글 핸들러 // 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => { const handleToggleActive = async (checked: boolean) => {
try { try {
// codeValue 또는 code_value가 없으면 에러 처리
const codeValue = code.codeValue || code.code_value;
if (!codeValue) {
return;
}
await updateCodeMutation.mutateAsync({ await updateCodeMutation.mutateAsync({
categoryCode, categoryCode,
codeValue: code.code_value, codeValue: codeValue,
data: { data: {
codeName: code.code_name, codeName: code.codeName || code.code_name,
codeNameEng: code.code_name_eng || "", codeNameEng: code.codeNameEng || code.code_name_eng || "",
description: code.description || "", description: code.description || "",
sortOrder: code.sort_order, sortOrder: code.sortOrder || code.sort_order,
isActive: checked ? "Y" : "N", isActive: checked ? "Y" : "N",
}, },
}); });
@ -70,12 +76,12 @@ export function SortableCodeItem({
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{code.code_name}</h3> <h3 className="font-medium text-gray-900">{code.codeName || code.code_name}</h3>
<Badge <Badge
variant={code.is_active === "Y" ? "default" : "secondary"} variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
className={cn( className={cn(
"cursor-pointer transition-colors", "cursor-pointer transition-colors",
code.is_active === "Y" code.isActive === "Y" || code.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900" ? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700", : "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50", updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
@ -84,16 +90,17 @@ export function SortableCodeItem({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!updateCodeMutation.isPending) { if (!updateCodeMutation.isPending) {
handleToggleActive(code.is_active !== "Y"); const isActive = code.isActive === "Y" || code.is_active === "Y";
handleToggleActive(!isActive);
} }
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{code.is_active === "Y" ? "활성" : "비활성"} {code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</div> </div>
<p className="mt-1 text-sm text-gray-600">{code.code_value}</p> <p className="mt-1 text-sm text-gray-600">{code.codeValue || code.code_value}</p>
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>} {code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
</div> </div>

View File

@ -215,8 +215,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}; };
checkIsMobile(); checkIsMobile();
window.addEventListener('resize', checkIsMobile); window.addEventListener("resize", checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile); return () => window.removeEventListener("resize", checkIsMobile);
}, []); }, []);
// 프로필 관련 로직 // 프로필 관련 로직
@ -322,18 +322,20 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return ( return (
<div key={menu.id}> <div key={menu.id}>
<div <div
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out h-10 ${ className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
pathname === menu.url pathname === menu.url
? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500" ? "border-l-4 border-blue-500 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
: isExpanded : isExpanded
? "bg-slate-100 text-slate-900" ? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900" : "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
} ${level > 0 ? "ml-6" : ""}`} } ${level > 0 ? "ml-6" : ""}`}
onClick={() => handleMenuClick(menu)} onClick={() => handleMenuClick(menu)}
> >
<div className="flex items-center min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center">
{menu.icon} {menu.icon}
<span className="ml-3 truncate" title={menu.name}>{menu.name}</span> <span className="ml-3 truncate" title={menu.name}>
{menu.name}
</span>
</div> </div>
{menu.hasChildren && ( {menu.hasChildren && (
<div className="ml-auto"> <div className="ml-auto">
@ -350,14 +352,16 @@ function AppLayoutInner({ children }: AppLayoutProps) {
key={child.id} key={child.id}
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${ className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
pathname === child.url pathname === child.url
? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500" ? "border-l-4 border-blue-500 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900" : "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`} }`}
onClick={() => handleMenuClick(child)} onClick={() => handleMenuClick(child)}
> >
<div className="flex items-center min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center">
{child.icon} {child.icon}
<span className="ml-3 truncate" title={child.name}>{child.name}</span> <span className="ml-3 truncate" title={child.name}>
{child.name}
</span>
</div> </div>
</div> </div>
))} ))}
@ -408,8 +412,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
className={`${ className={`${
isMobile isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40" ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "translate-x-0 relative top-0 z-auto" : "relative top-0 z-auto translate-x-0"
} flex h-full w-72 min-w-72 max-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`} } flex h-full w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
> >
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */} {/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{(user as ExtendedUserInfo)?.userType === "admin" && ( {(user as ExtendedUserInfo)?.userType === "admin" && (
@ -453,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside> </aside>
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */} {/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
<main className="flex-1 min-w-0 bg-white overflow-auto">{children}</main> <main className="min-w-0 flex-1 overflow-auto bg-white">{children}</main>
</div> </div>
{/* 프로필 수정 모달 */} {/* 프로필 수정 모달 */}
@ -461,7 +465,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
isOpen={isModalOpen} isOpen={isModalOpen}
user={user} user={user}
formData={formData} formData={formData}
selectedImage={selectedImage} selectedImage={selectedImage || ""}
isSaving={isSaving} isSaving={isSaving}
departments={departments} departments={departments}
alertModal={alertModal} alertModal={alertModal}

View File

@ -101,17 +101,21 @@ export function ProfileModal({
{/* 프로필 사진 섹션 */} {/* 프로필 사진 섹션 */}
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="relative"> <div className="relative">
<Avatar className="h-24 w-24"> <div className="relative flex h-24 w-24 shrink-0 overflow-hidden rounded-full">
{selectedImage ? ( {selectedImage && selectedImage.trim() !== "" ? (
<AvatarImage src={selectedImage} alt="프로필 사진 미리보기" /> <img
) : user?.photo ? ( src={selectedImage}
<AvatarImage src={user.photo} alt="기존 프로필 사진" /> alt="프로필 사진 미리보기"
className="aspect-square h-full w-full object-cover"
/>
) : ( ) : (
<AvatarFallback className="text-lg">{formData.userName?.substring(0, 1) || "U"}</AvatarFallback> <div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-2xl font-semibold text-slate-700">
{formData.userName?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)} )}
</Avatar> </div>
{(selectedImage || user?.photo) && ( {selectedImage && selectedImage.trim() !== "" ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
@ -121,7 +125,7 @@ export function ProfileModal({
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
)} ) : null}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@ -26,20 +26,38 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8"> <div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
{user.photo ? <AvatarImage src={user.photo} alt={user.userName || "User"} /> : null} {user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<AvatarFallback>{user.userName?.substring(0, 1) || "U"}</AvatarFallback> <img
</Avatar> src={user.photo}
alt={user.userName || "User"}
className="aspect-square h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 font-semibold text-slate-700">
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)}
</div>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end"> <DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{/* 프로필 사진 표시 */} {/* 프로필 사진 표시 */}
<Avatar className="h-12 w-12"> <div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
{user.photo ? <AvatarImage src={user.photo} alt={user.userName || "User"} /> : null} {user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<AvatarFallback>{user.userName?.substring(0, 1) || "U"}</AvatarFallback> <img
</Avatar> src={user.photo}
alt={user.userName || "User"}
className="aspect-square h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-base font-semibold text-slate-700">
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)}
</div>
{/* 사용자 정보 */} {/* 사용자 정보 */}
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">

View File

@ -29,6 +29,9 @@ export const MESSAGES = {
CONFIRM: "정말로 진행하시겠습니까?", CONFIRM: "정말로 진행하시겠습니까?",
NO_DATA: "데이터가 없습니다.", NO_DATA: "데이터가 없습니다.",
NO_MENUS: "사용 가능한 메뉴가 없습니다.", NO_MENUS: "사용 가능한 메뉴가 없습니다.",
FILE_SIZE_ERROR: "파일 크기가 너무 큽니다. 5MB 이하의 파일을 선택해주세요.",
FILE_TYPE_ERROR: "이미지 파일만 업로드 가능합니다.",
PROFILE_SAVE_ERROR: "프로필 저장 중 오류가 발생했습니다.",
} as const; } as const;
export const MENU_ICONS = { export const MENU_ICONS = {

View File

@ -27,7 +27,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
positionName: "", positionName: "",
locale: "", locale: "",
}, },
selectedImage: "", selectedImage: null,
selectedFile: null, selectedFile: null,
isSaving: false, isSaving: false,
}); });
@ -80,13 +80,6 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
*/ */
const openProfileModal = useCallback(() => { const openProfileModal = useCallback(() => {
if (user) { if (user) {
console.log("🔍 프로필 모달 열기 - 사용자 정보:", {
userName: user.userName,
email: user.email,
deptName: user.deptName,
locale: user.locale,
});
// 부서 목록 로드 // 부서 목록 로드
loadDepartments(); loadDepartments();
@ -100,7 +93,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
positionName: user.positionName || "", positionName: user.positionName || "",
locale: user.locale || "KR", // 기본값을 KR로 설정 locale: user.locale || "KR", // 기본값을 KR로 설정
}, },
selectedImage: user.photo || "", selectedImage: user.photo || null,
selectedFile: null, selectedFile: null,
isSaving: false, isSaving: false,
})); }));
@ -113,6 +106,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
const closeProfileModal = useCallback(() => { const closeProfileModal = useCallback(() => {
setModalState((prev) => ({ setModalState((prev) => ({
...prev, ...prev,
selectedImage: null,
selectedFile: null,
isOpen: false, isOpen: false,
})); }));
}, []); }, []);
@ -173,17 +168,21 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
* *
*/ */
const removeImage = useCallback(() => { const removeImage = useCallback(() => {
setModalState((prev) => ({
...prev,
selectedImage: "",
selectedFile: null,
}));
// 파일 input 초기화 // 파일 input 초기화
const fileInput = document.getElementById("profile-image-input") as HTMLInputElement; const fileInput = document.getElementById("profile-image-input") as HTMLInputElement;
if (fileInput) { if (fileInput) {
fileInput.value = ""; fileInput.value = "";
} }
// 상태 업데이트 - 명시적으로 null로 설정하여 AvatarFallback이 확실히 표시되도록 함
setModalState((prev) => {
const newState = {
...prev,
selectedImage: null, // 빈 문자열 대신 null로 설정
selectedFile: null,
};
return newState;
});
}, []); }, []);
/** /**
@ -195,8 +194,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
setModalState((prev) => ({ ...prev, isSaving: true })); setModalState((prev) => ({ ...prev, isSaving: true }));
try { try {
// 선택된 이미지가 있으면 Base64로 변환, 없으면 기존 이미지 유지 // 이미지 데이터 결정 로직
let photoData = user.photo || ""; let photoData: string | null | undefined = undefined;
if (modalState.selectedFile) { if (modalState.selectedFile) {
// 새로 선택된 파일을 Base64로 변환 // 새로 선택된 파일을 Base64로 변환
@ -207,26 +206,29 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
}; };
reader.readAsDataURL(modalState.selectedFile!); reader.readAsDataURL(modalState.selectedFile!);
}); });
} else if (modalState.selectedImage === null || modalState.selectedImage === "") {
// 이미지가 명시적으로 삭제된 경우 (X 버튼 클릭)
photoData = null;
} else if (modalState.selectedImage && modalState.selectedImage !== user.photo) { } else if (modalState.selectedImage && modalState.selectedImage !== user.photo) {
// 미리보기 이미지가 변경된 경우 사용 // 미리보기 이미지가 변경된 경우
photoData = modalState.selectedImage; photoData = modalState.selectedImage;
} }
// 사용자 정보 저장 데이터 준비 // 사용자 정보 저장 데이터 준비
const updateData = { const updateData: any = {
userName: modalState.formData.userName, userName: modalState.formData.userName,
email: modalState.formData.email, email: modalState.formData.email,
locale: modalState.formData.locale, locale: modalState.formData.locale,
photo: photoData !== user.photo ? photoData : undefined, // 변경된 경우만 전송
}; };
console.log("프로필 업데이트 요청:", updateData); // photo가 변경된 경우에만 추가 (undefined가 아닌 경우)
if (photoData !== undefined) {
updateData.photo = photoData;
}
// API 호출 (JWT 토큰 자동 포함) // API 호출 (JWT 토큰 자동 포함)
const response = await apiCall("PUT", "/admin/profile", updateData); const response = await apiCall("PUT", "/admin/profile", updateData);
console.log("프로필 업데이트 응답:", response);
if (response.success || (response as any).result) { if (response.success || (response as any).result) {
// locale이 변경된 경우 전역 변수와 localStorage 업데이트 // locale이 변경된 경우 전역 변수와 localStorage 업데이트
const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale; const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale;
@ -234,7 +236,6 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
// 🎯 useMultiLang의 콜백 시스템을 사용하여 모든 컴포넌트에 즉시 알림 // 🎯 useMultiLang의 콜백 시스템을 사용하여 모든 컴포넌트에 즉시 알림
const { notifyLanguageChange } = await import("@/hooks/useMultiLang"); const { notifyLanguageChange } = await import("@/hooks/useMultiLang");
notifyLanguageChange(modalState.formData.locale); notifyLanguageChange(modalState.formData.locale);
console.log("🌍 사용자 locale 업데이트 (콜백 방식):", modalState.formData.locale);
} }
// 성공: 사용자 정보 새로고침 // 성공: 사용자 정보 새로고침
@ -242,15 +243,17 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
// locale이 변경된 경우 메뉴도 새로고침 // locale이 변경된 경우 메뉴도 새로고침
if (localeChanged && refreshMenus) { if (localeChanged && refreshMenus) {
console.log("🔄 locale 변경으로 인한 메뉴 새로고침 시작");
await refreshMenus(); await refreshMenus();
console.log("✅ 메뉴 새로고침 완료");
} }
// 모달 상태 초기화 (저장 후 즉시 반영을 위해)
setModalState((prev) => ({ setModalState((prev) => ({
...prev, ...prev,
selectedFile: null, selectedFile: null,
selectedImage: null,
isOpen: false, isOpen: false,
})); }));
showAlert("저장 완료", "프로필이 성공적으로 업데이트되었습니다.", "success"); showAlert("저장 완료", "프로필이 성공적으로 업데이트되었습니다.", "success");
} else { } else {
throw new Error((response as any).message || "프로필 업데이트 실패"); throw new Error((response as any).message || "프로필 업데이트 실패");

View File

@ -17,13 +17,22 @@ export interface CodeCategory {
export type CategoryInfo = CodeCategory; export type CategoryInfo = CodeCategory;
export interface CodeInfo { export interface CodeInfo {
code_category: string; // 백엔드 응답 필드 (변환된 형태)
code_value: string; codeValue?: string;
code_name: string; codeName?: string;
code_name_eng?: string | null; codeNameEng?: string | null;
description?: string | null; description?: string | null;
sort_order: number; sortOrder?: number;
is_active: string; isActive?: string | boolean;
useYn?: string;
// 기존 필드 (하위 호환성을 위해 유지)
code_category?: string;
code_value?: string;
code_name?: string;
code_name_eng?: string | null;
sort_order?: number;
is_active?: string;
created_date?: string | null; created_date?: string | null;
created_by?: string | null; created_by?: string | null;
updated_date?: string | null; updated_date?: string | null;

View File

@ -13,7 +13,7 @@ export interface ProfileFormData {
export interface ProfileModalState { export interface ProfileModalState {
isOpen: boolean; isOpen: boolean;
formData: ProfileFormData; formData: ProfileFormData;
selectedImage: string; selectedImage: string | null;
selectedFile: File | null; selectedFile: File | null;
isSaving: boolean; isSaving: boolean;
} }

View File

@ -0,0 +1,384 @@
/**
*
*
* SQL
*/
export class DatabaseValidator {
// PostgreSQL 예약어 목록 (주요 키워드만)
private static readonly RESERVED_WORDS = new Set([
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"FROM",
"WHERE",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"FULL",
"ON",
"GROUP",
"BY",
"ORDER",
"HAVING",
"LIMIT",
"OFFSET",
"UNION",
"ALL",
"DISTINCT",
"AS",
"AND",
"OR",
"NOT",
"NULL",
"TRUE",
"FALSE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"IF",
"EXISTS",
"IN",
"BETWEEN",
"LIKE",
"ILIKE",
"SIMILAR",
"TO",
"CREATE",
"DROP",
"ALTER",
"TABLE",
"INDEX",
"VIEW",
"FUNCTION",
"PROCEDURE",
"TRIGGER",
"DATABASE",
"SCHEMA",
"USER",
"ROLE",
"GRANT",
"REVOKE",
"COMMIT",
"ROLLBACK",
"BEGIN",
"TRANSACTION",
"SAVEPOINT",
"RELEASE",
"CONSTRAINT",
"PRIMARY",
"FOREIGN",
"KEY",
"UNIQUE",
"CHECK",
"DEFAULT",
"REFERENCES",
"CASCADE",
"RESTRICT",
"SET",
"ACTION",
"DEFERRABLE",
"INITIALLY",
"DEFERRED",
"IMMEDIATE",
"MATCH",
"PARTIAL",
"SIMPLE",
"FULL",
]);
// 유효한 PostgreSQL 데이터 타입 패턴
private static readonly DATA_TYPE_PATTERNS = [
/^(SMALLINT|INTEGER|BIGINT|DECIMAL|NUMERIC|REAL|DOUBLE\s+PRECISION|SMALLSERIAL|SERIAL|BIGSERIAL)$/i,
/^(MONEY)$/i,
/^(CHARACTER\s+VARYING|VARCHAR|CHARACTER|CHAR|TEXT)(\(\d+\))?$/i,
/^(BYTEA)$/i,
/^(TIMESTAMP|TIME)(\s+(WITH|WITHOUT)\s+TIME\s+ZONE)?(\(\d+\))?$/i,
/^(DATE|INTERVAL)(\(\d+\))?$/i,
/^(BOOLEAN|BOOL)$/i,
/^(POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)$/i,
/^(CIDR|INET|MACADDR|MACADDR8)$/i,
/^(BIT|BIT\s+VARYING)(\(\d+\))?$/i,
/^(TSVECTOR|TSQUERY)$/i,
/^(UUID)$/i,
/^(XML)$/i,
/^(JSON|JSONB)$/i,
/^(ARRAY|INTEGER\[\]|TEXT\[\]|VARCHAR\[\])$/i,
/^(DECIMAL|NUMERIC)\(\d+,\d+\)$/i,
];
/**
*
*/
static validateTableName(tableName: string): boolean {
if (!tableName || typeof tableName !== "string") {
return false;
}
// 길이 제한 (PostgreSQL 최대 63자)
if (tableName.length === 0 || tableName.length > 63) {
return false;
}
// 유효한 식별자 패턴 (문자 또는 밑줄로 시작, 문자/숫자/밑줄만 포함)
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(tableName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(tableName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateColumnName(columnName: string): boolean {
if (!columnName || typeof columnName !== "string") {
return false;
}
// 길이 제한
if (columnName.length === 0 || columnName.length > 63) {
return false;
}
// JSON 연산자 포함 컬럼명 허용 (예: config->>'type', data->>path)
if (columnName.includes("->") || columnName.includes("->>")) {
const baseName = columnName.split(/->|->>/)[0];
return this.validateColumnName(baseName);
}
// 유효한 식별자 패턴
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(columnName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(columnName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateDataType(dataType: string): boolean {
if (!dataType || typeof dataType !== "string") {
return false;
}
const normalizedType = dataType.trim().toUpperCase();
return this.DATA_TYPE_PATTERNS.some((pattern) =>
pattern.test(normalizedType)
);
}
/**
* WHERE
*/
static validateWhereClause(whereClause: Record<string, any>): boolean {
if (!whereClause || typeof whereClause !== "object") {
return false;
}
// 모든 키가 유효한 컬럼명인지 확인
for (const key of Object.keys(whereClause)) {
if (!this.validateColumnName(key)) {
return false;
}
}
return true;
}
/**
*
*/
static validatePagination(page: number, pageSize: number): boolean {
// 페이지 번호는 1 이상
if (!Number.isInteger(page) || page < 1) {
return false;
}
// 페이지 크기는 1 이상 1000 이하
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) {
return false;
}
return true;
}
/**
* ORDER BY
*/
static validateOrderBy(orderBy: string): boolean {
if (!orderBy || typeof orderBy !== "string") {
return false;
}
// 기본 패턴: column_name [ASC|DESC]
const orderPattern = /^[a-zA-Z_][a-zA-Z0-9_]*(\s+(ASC|DESC))?$/i;
// 여러 컬럼 정렬의 경우 콤마로 분리하여 각각 검증
const orderClauses = orderBy.split(",").map((clause) => clause.trim());
return orderClauses.every((clause) => {
return (
orderPattern.test(clause) &&
this.validateColumnName(clause.split(/\s+/)[0])
);
});
}
/**
* UUID
*/
static validateUUID(uuid: string): boolean {
if (!uuid || typeof uuid !== "string") {
return false;
}
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidPattern.test(uuid);
}
/**
*
*/
static validateEmail(email: string): boolean {
if (!email || typeof email !== "string") {
return false;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email) && email.length <= 254;
}
/**
* SQL
*/
static containsSqlInjection(input: string): boolean {
if (!input || typeof input !== "string") {
return false;
}
// 위험한 SQL 패턴들
const dangerousPatterns = [
/('|(\\')|(;)|(--)|(\s+(OR|AND)\s+\d+\s*=\s*\d+)/i,
/(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE)/i,
/(\bxp_\w+|\bsp_\w+)/i, // SQL Server 확장 프로시저
/(script|javascript|vbscript|onload|onerror)/i, // XSS 패턴
];
return dangerousPatterns.some((pattern) => pattern.test(input));
}
/**
*
*/
static validateNumberRange(
value: number,
min?: number,
max?: number
): boolean {
if (typeof value !== "number" || !Number.isFinite(value)) {
return false;
}
if (min !== undefined && value < min) {
return false;
}
if (max !== undefined && value > max) {
return false;
}
return true;
}
/**
*
*/
static validateStringLength(
value: string,
minLength?: number,
maxLength?: number
): boolean {
if (typeof value !== "string") {
return false;
}
if (minLength !== undefined && value.length < minLength) {
return false;
}
if (maxLength !== undefined && value.length > maxLength) {
return false;
}
return true;
}
/**
* JSON
*/
static validateJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString);
return true;
} catch {
return false;
}
}
/**
* (ISO 8601)
*/
static validateDateISO(dateString: string): boolean {
if (!dateString || typeof dateString !== "string") {
return false;
}
const date = new Date(dateString);
return !isNaN(date.getTime()) && dateString === date.toISOString();
}
/**
*
*/
static validateArray<T>(
array: any[],
validator: (item: T) => boolean,
minLength?: number,
maxLength?: number
): boolean {
if (!Array.isArray(array)) {
return false;
}
if (minLength !== undefined && array.length < minLength) {
return false;
}
if (maxLength !== undefined && array.length > maxLength) {
return false;
}
return array.every((item) => validator(item));
}
}

290
src/utils/queryBuilder.ts Normal file
View File

@ -0,0 +1,290 @@
/**
* SQL
*
* Raw Query
*/
export interface SelectOptions {
columns?: string[];
where?: Record<string, any>;
joins?: JoinClause[];
orderBy?: string;
limit?: number;
offset?: number;
groupBy?: string[];
having?: Record<string, any>;
}
export interface JoinClause {
type: "INNER" | "LEFT" | "RIGHT" | "FULL";
table: string;
on: string;
}
export interface InsertOptions {
returning?: string[];
onConflict?: {
columns: string[];
action: "DO NOTHING" | "DO UPDATE";
updateSet?: string[];
};
}
export interface UpdateOptions {
returning?: string[];
}
export interface QueryResult {
query: string;
params: any[];
}
export class QueryBuilder {
/**
* SELECT
*/
static select(table: string, options: SelectOptions = {}): QueryResult {
const {
columns = ["*"],
where = {},
joins = [],
orderBy,
limit,
offset,
groupBy = [],
having = {},
} = options;
let query = `SELECT ${columns.join(", ")} FROM ${table}`;
const params: any[] = [];
let paramIndex = 1;
// JOIN 절 추가
for (const join of joins) {
query += ` ${join.type} JOIN ${join.table} ON ${join.on}`;
}
// WHERE 절 추가
const whereConditions = Object.keys(where);
if (whereConditions.length > 0) {
const whereClause = whereConditions
.map((key) => {
params.push(where[key]);
return `${key} = $${paramIndex++}`;
})
.join(" AND ");
query += ` WHERE ${whereClause}`;
}
// GROUP BY 절 추가
if (groupBy.length > 0) {
query += ` GROUP BY ${groupBy.join(", ")}`;
}
// HAVING 절 추가
const havingConditions = Object.keys(having);
if (havingConditions.length > 0) {
const havingClause = havingConditions
.map((key) => {
params.push(having[key]);
return `${key} = $${paramIndex++}`;
})
.join(" AND ");
query += ` HAVING ${havingClause}`;
}
// ORDER BY 절 추가
if (orderBy) {
query += ` ORDER BY ${orderBy}`;
}
// LIMIT 절 추가
if (limit !== undefined) {
params.push(limit);
query += ` LIMIT $${paramIndex++}`;
}
// OFFSET 절 추가
if (offset !== undefined) {
params.push(offset);
query += ` OFFSET $${paramIndex++}`;
}
return { query, params };
}
/**
* INSERT
*/
static insert(
table: string,
data: Record<string, any>,
options: InsertOptions = {}
): QueryResult {
const { returning = [], onConflict } = options;
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
let query = `INSERT INTO ${table} (${columns.join(
", "
)}) VALUES (${placeholders})`;
// ON CONFLICT 절 추가
if (onConflict) {
query += ` ON CONFLICT (${onConflict.columns.join(", ")})`;
if (onConflict.action === "DO NOTHING") {
query += " DO NOTHING";
} else if (onConflict.action === "DO UPDATE" && onConflict.updateSet) {
const updateSet = onConflict.updateSet
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
query += ` DO UPDATE SET ${updateSet}`;
}
}
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(", ")}`;
}
return { query, params: values };
}
/**
* UPDATE
*/
static update(
table: string,
data: Record<string, any>,
where: Record<string, any>,
options: UpdateOptions = {}
): QueryResult {
const { returning = [] } = options;
const dataKeys = Object.keys(data);
const dataValues = Object.values(data);
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let paramIndex = 1;
// SET 절 생성
const setClause = dataKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(", ");
// WHERE 절 생성
const whereClause = whereKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(" AND ");
let query = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`;
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(", ")}`;
}
const params = [...dataValues, ...whereValues];
return { query, params };
}
/**
* DELETE
*/
static delete(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
const query = `DELETE FROM ${table} WHERE ${whereClause}`;
return { query, params: whereValues };
}
/**
* COUNT
*/
static count(table: string, where: Record<string, any> = {}): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let query = `SELECT COUNT(*) as count FROM ${table}`;
if (whereKeys.length > 0) {
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
query += ` WHERE ${whereClause}`;
}
return { query, params: whereValues };
}
/**
* EXISTS
*/
static exists(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
const query = `SELECT EXISTS(SELECT 1 FROM ${table} WHERE ${whereClause}) as exists`;
return { query, params: whereValues };
}
/**
* WHERE ( )
*/
static buildWhereClause(
conditions: Record<string, any>,
startParamIndex: number = 1
): { clause: string; params: any[]; nextParamIndex: number } {
const keys = Object.keys(conditions);
const params: any[] = [];
let paramIndex = startParamIndex;
if (keys.length === 0) {
return { clause: "", params: [], nextParamIndex: paramIndex };
}
const clause = keys
.map((key) => {
const value = conditions[key];
// 특수 연산자 처리
if (key.includes(">>") || key.includes("->")) {
// JSON 쿼리
params.push(value);
return `${key} = $${paramIndex++}`;
} else if (Array.isArray(value)) {
// IN 절
const placeholders = value.map(() => `$${paramIndex++}`).join(", ");
params.push(...value);
return `${key} IN (${placeholders})`;
} else if (value === null) {
// NULL 체크
return `${key} IS NULL`;
} else {
// 일반 조건
params.push(value);
return `${key} = $${paramIndex++}`;
}
})
.join(" AND ");
return { clause, params, nextParamIndex: paramIndex };
}
}