1. 들어가며
내가 재직 중인 회사의 사내 근태(출·퇴근, 외근, 출장 등)의 경우 다음과 같은 구조를 가진다.
사내 DB -> 외주 근태 서비스 -> 사내 DB 업데이트
출·퇴근의 경우 캡스에 출근을 찍으면, 해당 데이터가 DB에 저장된다.
그 후 DB에 있는 값을 외부 근태 서비스로 전달하고, 근태 서비스에 반영되면 사내 DB에 근태 전송 완료 플래그를 업데이트한다.
얼레벌레 돌아가는 서비스를 마이그레이션 하겠단 결정을 한 이유는 다음과 같다.
1. 회사 내부의 대부분 코드가 asp로 적혀있었고, 로깅도 하지 않았기에 문제가 발생하면 어디서 문제가 발생했는지 찾기 힘들었다. 추가로 급하게 짜인 코드라 그런지 의미 없는 변수명, 꼬여있는 로직 등으로 코드에 대한 분석이 쉽지 않았다.
2. asp 코드의 특성상 코드 수정 후 저장하면 저장사항이 바로 반영된다. 서버에 들어올 수 있는 사람이 여러 명(외주 업체)이었기에 변경사항이 바로 서비스에 반영되는 특성은 서비스의 안정성을 매우 떨어뜨렸다.
3. 내가 asp에 익숙하지 않고, asp에 익숙한 사람이 거의 없어지고 있다. 내가 더 편하게, 나 이후에 들어 올 사람들이 더 편하게 일하길 바라는 마음에 마이그레이션을 결정했다.
그러므로 asp로 되어있는 사내 근태 서비스를 기능 단위로 NestJS로 전환하려 한다.
2. 기존 서비스 로직 분석
출·퇴근 서비스는 다음과 같은 로직으로 돌아간다.
- MS-SQL 내부 테이블에서 출퇴근 데이터를 조회한다. 조회 시 날짜와 외부 근태 서비스에 반영이 됐는지 여부를 통해 데이터를 호출한다.
- 외주 API에 맞게 데이터를 가공한다.
- 외주 API를 호출한다.
- 응답에 따라 내부 테이블을 업데이트한다.
위 로직을 1분마다 반복하는 것이 내부 로직이다.
3. 마이그레이션을 해보자!
먼저 기존 코드가 가지고 있는 문제점을 정리해 보았다.
- 하드코딩 되어있는 정보
- 의미 없는 변수명과 파일명
- 변경사항이 바로 적용되는 문제
- 공통 로직 분리가 되어있지 않아 반복되는 코드
- 전혀 없는 보안
각 문제를 해결하기 위한 해결책을 생각해 보았다.
1. 하드코딩 되어있는 정보
가장 중요한 DB 서버에 대한 정보(서버 ip 주소, id, pw 등 )가 그대로 노출되어 있었다.
해당 값은 전부 환경변수로 관리한다.
2. 의미 없는 변수명과 파일명
attendance1.asp, attendance2.asp 등 비슷한 이름의 여러 파일과 a, b 등의 여러 변수명이 섞여있었다.
이런 부분은 ai를 통해 분석했다.
asp 코드 내 변수명 옆에 주석을 달아 해당 변수들이 어떤 역할을 하는지 표기했고, 이를 바탕으로 마이그레이션을 진행했다.
3. 변경사항이 바로 적용되는 문제
스크립트 언어가 가지고 있는 공통적인 문제이다.
변경사항을 저장하면 바로 코드에 반영된다.
이는 Docker로 띄울 생각을 했지만 윈도 서버 버전이 낮아 Docker가 돌아가지 않았다.🥲
그러므로 PM2를 사용하기로 결정했다.
4. 공통 로직 분리가 되어있지 않아 반복되는 코드
외주 서비스로 요청을 보내는 코드들이 반복적으로 들어있었다.
같은 코드가 많다 보니 한 파일에서 로직을 수정하면 같은 코드를 여러 번 수정해야 하는 문제가 있었다.
5. 에러발생 시 즉각적으로 알 수 없고, 로깅이 안 되는 문제
현재 에러가 발생한 경우, 사용자가 직접 문제를 겪고 나서 우리에게 알려주게 된다.
에러 발생 시 알림이 없고, 로깅도 안되기에 어디서 어떤 문제로 에러가 발생했는지 알 수 없다.
애로 사항
공통 로직 분리
의존성 분리를 하기 위해 다음과 같이 폴더 구조를 구성했다.
src/
├── main.ts
├── app.module.ts
│
├── database/ # 데이터베이스 레이어 (Raw Query만)
│ ├── oracle/ # Oracle 연결 관리
│ │ ├── oracle.provider.ts # Connection Pool Provider
│ │ └── oracle.service.ts # Raw Query 래퍼
│ ├── mssql/ # MS-SQL 연결 관리
│ │ ├── mssql.provider.ts # Connection Pool Provider
│ │ └── mssql.service.ts # Raw Query 래퍼
│ └── types/ # 타입 정의
│ ├── oracle.types.ts # Oracle 테이블 타입
│ └── mssql.types.ts # MS-SQL 테이블 타입
│
├── modules/ # 기능별 모듈
│ ├── attendance/ # 출장 관리 모듈
│ │ ├── attendance.module.ts
| | ├── attendance.facade.ts # 서비스 메서드를 조합할 Facade
│ │ ├── attendacne.service.ts # 비즈니스 로직 + Raw SQL
│ │ ├── attendance.controller.ts # REST API 엔드포인트
│ │ └── dto/ # 데이터 전송 객체
│ │ ├── sync-attendance.dto.ts
│ │ └── update-business-trip.dto.ts
│ │
│ ├── vacation/ # 휴가 관리 모듈
│ │ ├── vacation.module.ts
| | ├── vacation.facade.ts
│ │ ├── vacation.service.ts
│ │ ├── vacation.controller.ts
│ │ └── dto/
│ │
사내 서비스와 외주 서비스의 동기화를 위해 값들을 모두 외주 서비스 API에 요청을 보낸다.
그러므로 외주 서비스에 요청을 보내는 부분을 모듈로 분리했다.
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface AttendanceParams {
employeeId: string; // 직원 ID
workDate: string; // 근무 일자 (YYYYMMDD)
type: string; // 출퇴근 구분
date: string; // 일자
time: string; // 시간 (HHMM 형식)
}
@Injectable()
export class ExternalApiService {
private readonly logger = new Logger(ExternalApiService.name);
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly tenantKey: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>('EXTERNAL_API_BASE_URL');
this.apiKey = this.configService.get<string>('EXTERNAL_API_KEY');
this.tenantKey = this.configService.get<string>('EXTERNAL_TENANT_KEY');
}
/**
* 외부 API 공통 호출 메서드
* @param endpoint - API 엔드포인트
* @param params - 요청 파라미터
* @returns API 응답
*/
private async request<T = any>(
endpoint: string,
params: Record<string, any>,
): Promise<T> {
const url = new URL(`${this.baseUrl}${endpoint}`);
// URL 파라미터 추가
Object.keys(params).forEach((key) =>
url.searchParams.append(key, params[key]),
);
try {
const res = await fetch(url, {
method: 'GET',
headers: {
tenantKey: this.tenantKey,
apiKey: this.apiKey,
},
});
if (!res.ok) {
this.logger.error(
`외부 API 호출 실패: ${endpoint}, 상태 코드: ${res.status}`,
);
throw new Error(`네트워크 응답 오류: ${res.statusText}`);
}
const data = await res.json();
return data;
} catch (error) {
this.logger.error(`외부 API 호출 중 에러 발생: ${endpoint}`, error);
throw error;
}
}
/**
* 출퇴근 기록 전송
* @param params - 출퇴근 기록 파라미터
*/
async sendAttendance(params: AttendanceParams): Promise<any> {
this.logger.log(
`출퇴근 기록 전송: 직원 ID ${params.employeeId}, 날짜 ${params.workDate}, 구분 ${params.type === '1' ? '출근' : '퇴근'}`,
);
try {
const result = await this.request('/attendance/record', params);
this.logger.log(
`출퇴근 기록 전송 성공: 직원 ID ${params.employeeId}, 날짜 ${params.workDate}`,
);
return result;
} catch (error) {
this.logger.error(
`출퇴근 기록 전송 실패: 직원 ID ${params.employeeId}, 날짜 ${params.workDate}, 구분 ${params.type}`,
error,
);
throw error;
}
}
}
ORM 사용 여부
난 대부분의 경우 ORM을 사용했다.
그래서 마이그레이션에도 ORM을 사용하려 했지만.... 문제가 많았다.
회사 내 DB 서버로 Oracle과 MSSQL을 사용한다.
문제는 MSSQL 버전이 너무 낮아 이를 지원하는 ORM이 없었다.
추가로 Node.js의 경우 ORM이 매우 많고, 스프링과 다르게 천하통일된 ORM이 없었다.
그러므로 만약 ORM 개발자가 개발을 멈추면 서비스에 큰 장애가 생길 수 있었다.
그러다 보니 Raw Query에 대한 래퍼 코드를 직접 만들어 Raw Query를 그대로 사용하기로 결정했다.
Raw Query를 사용함에 있어 큰 문제가 되는 것이 SQL Injection이었다.
이를 해결하기 위해 Prepared Statement를 사용했다.
Prepared Statement란 SQL 쿼리를 컴파일과 실행 두 단계로 분리하는 방식이다.
먼저 준비 단계에선 쿼리 구조를 미리 파싱하고 컴파일한다.
-- SQL 템플릿을 먼저 데이터베이스에 전송
SELECT * FROM users WHERE username = ? AND password = ?
데이터베이스는 이에 따른 실행 계획을 생성한다.
위 쿼리의 ? 가 나중에 파라미터가 들어갈 자리이다.
실행 단계에선 실제 값을 바인딩한다.
이미 컴파일된 쿼리에 파라미터만 전달하는 방식이다.
일반 SQL과 Prepared Statement를 비교하면 이렇다.
일반 SQL
const sql = "SELECT * FROM users WHERE id = " + userInput;
// userInput = "1 OR 1=1" 입력 시
// 실행: SELECT * FROM users WHERE id = 1 OR 1=1 (모든 데이터 노출!)
Prepared Statement
const sql = "SELECT * FROM users WHERE id = ?";
pstmt.setString(1, "1 OR 1=1");
// 실행: SELECT * FROM users WHERE id = '1 OR 1=1' (문자열로 처리)
파라미터 값이 SQL 명령어가 아닌 데이터로만 처리되기 때문에 SQL Injection이 불가능해진다.
이 외에도 Preapred Statement가 가지는 성능 이점 등 여러 장점이 있다.
Raw Query 를 사용하므로 쿼리 래퍼를 만들었다.
import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common';
import * as sql from 'mssql';
import { MSSQL_POOL } from './mssql.provider';
@Injectable()
export class MssqlService implements OnModuleDestroy {
constructor(@Inject(MSSQL_POOL) private pool: sql.ConnectionPool) {}
/**
* SELECT 쿼리 실행
*/
async query<T = any>(
sqlQuery: string,
params: { [key: string]: any } = {},
): Promise<T[]> {
try {
const request = this.pool.request();
// 파라미터 바인딩
for (const [key, value] of Object.entries(params)) {
request.input(key, value);
}
const result = await request.query(sqlQuery);
return result.recordset as T[];
} catch (error) {
console.error('MS-SQL 쿼리 실패:', error);
throw error;
}
}
/**
* INSERT/UPDATE/DELETE 실행
*/
async execute(
sqlQuery: string,
params: { [key: string]: any } = {},
): Promise<sql.IResult<any>> {
try {
const request = this.pool.request();
// 파라미터 바인딩
for (const [key, value] of Object.entries(params)) {
request.input(key, value);
}
const result = await request.query(sqlQuery);
console.log(`✅ ${result.rowsAffected[0]} 행이 영향을 받았습니다.`);
return result;
} catch (error) {
console.error('MS-SQL execute 실패:', error);
throw error;
}
}
/**
* 트랜잭션 실행
*/
async transaction<T>(
callback: (transaction: sql.Transaction) => Promise<T>,
): Promise<T> {
const transaction = new sql.Transaction(this.pool);
try {
await transaction.begin();
const result = await callback(transaction);
await transaction.commit();
console.log('✅ 트랜잭션 커밋 완료');
return result;
} catch (error) {
await transaction.rollback();
console.warn('⚠️ 트랜잭션 롤백 완료');
throw error;
}
}
/**
* 앱 종료 시 Pool 정리
*/
async onModuleDestroy() {
try {
await this.pool.close();
console.log('✅ MS-SQL Pool 종료');
} catch (error) {
console.error('MS-SQL Pool 종료 실패:', error);
}
}
}
위 코드에서 다음 부분이 파라미터 바인딩(Prepared Statement)에 해당한다.
// 파라미터 바인딩
for (const [key, value] of Object.entries(params)) {
request.input(key, value);
}
에러 알림과 로깅
먼저 로깅이 안되는 문제가 컸다.
빠르게 마이그레이션을 해야 했고, 최대한 비용을 절감해야 하기에 winston을 사용해 로그 파일을 저장하고, 사내 메신저인 잔디로 알림을 보내기로 했다.
1. Winston을 전역 로거로 설정했다.
우리는 단일 서버를 사용하고 있었기에, ELK, Datadog과 같은 서비스를 붙이기엔 오버스펙이라 판단했다.
Winston을 사용할 경우, nest-winston 같은 라이브러리를 사용해 빠르게 우리 서비스와 통합할 수 있었고, 무료, 잔디 연동 등 여러 장점이 있기에 Winston을 사용했다.
2. 로그를 여러 곳으로 전송한다.
개발 도중에 봐야 할 로그, 전체 로그, 에러 저장 로그, 실시간 알림(잔디) 이렇게 한 번 로그를 찍으면 4 곳에 로그를 찍거나 저장해야 했다.
그러므로 winston-transport 라이브러리를 사용해 로그를 관리했다.
- Console Transport - 개발 중 실시간 확인용
- 모든 레벨의 로그 출력 - DailyRotateFile (일반 로그) - 전체 로그 저장
- 날짜별 파일 자동 생성 (app-2025-01-15.log)
- 14일 보관, 자동 압축
- 디버깅 시 전체 흐름 파악 가능 - DailyRotateFile (에러 로그) - 에러만 별도 관리
- error 레벨만 필터링
- 30일 보관 (일반 로그보다 오래 보관) - Custom Jandi Transport - 실시간 알림
- warn, error 레벨만 전송
- 에러 발생 시 바로 알 수 있도록
3. Exception Filter로 에러를 통합 처리한다.
Exception Filter를 사용해 모든 에러를 잡아 한 번의 Winston 로깅으로 콘솔 출력, 파일 저장, 잔디 알림을 구현했다.

4. 마치며
현재 사용하고 있는 근태 시스템엔 여러 모듈이 있다.
이번엔 그 중 출·퇴근만 NestJS로 마이그레이션 했다.
앞으로 모든 모듈을 NestJS로 마이그레이션 하고, 그 과정을 남겨보도록 하겠다.