733 lines
21 KiB
Markdown
733 lines
21 KiB
Markdown
|
|
# 🔐 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일
|
||
|
|
**담당자**: 백엔드 개발팀
|