ERP-node/PHASE1.5_AUTH_MIGRATION_PLA...

21 KiB

🔐 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개)

// 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 코드:

const userInfo = await prisma.user_info.findUnique({
  where: { user_id: userId },
  select: { user_password: true },
});

새로운 Raw Query 코드:

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 코드:

const userInfo = await prisma.user_info.findUnique({
  where: { user_id: userId },
  select: {
    sabun: true,
    user_id: true,
    user_name: true,
    // ... 20개 필드
  },
});

새로운 Raw Query 코드:

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 코드:

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 코드:

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 코드:

const companyInfo = await prisma.company_mng.findFirst({
  where: { company_code: userInfo.company_code || "ILSHIN" },
  select: { company_name: true },
});

새로운 Raw Query 코드:

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 코드

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}`
      );
    }
  }
}

🧪 테스트 계획

단위 테스트

// 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();
    });
  });
});

통합 테스트

// 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 문 변경 (prismaquery)
  • 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% 이내)
  • 에러 처리 및 로깅 정상 동작

📚 참고 문서


작성일: 2025-09-30 예상 소요 시간: 2-3일 담당자: 백엔드 개발팀