n8n 커스텀 노드 리팩토링: 타입 안전성과 성능을 동시에 잡는 7가지 패턴
코드 중복 제거, 타입 정의, Connection Pooling까지 - 실제 프로덕션 코드 개선 사례
1. 문제 상황
n8n 워크플로우 자동화 플랫폼용 커스텀 이메일 노드를 개발하던 중, 코드 리뷰에서 여러 문제점이 발견되었습니다.
발견된 문제들
✘ 자격증명 검증 로직이 2곳에서 중복
✘ 헬퍼 함수가 execute() 내부에 정의되어 매 실행마다 재생성
✘ SMTP Transporter가 루프 내에서 매번 새로 생성
✘ 타입 단언(as string)이 반복적으로 사용됨
✘ 옵션 필드 처리 코드가 3번 반복
Before: 문제가 있는 코드 구조
// ❌ 문제 1: 타입 정의 없이 any 타입 사용
const mailOptions: IDataObject = { // IDataObject는 실질적으로 any
from: fromEmail,
to: toEmail,
subject,
};
// ❌ 문제 2: 자격증명 검증이 두 곳에서 중복
// employeeAuthTest 메서드에서
if (!credentials.username || (credentials.username as string).trim() === '') {
return { status: 'Error', message: 'Username is required' };
}
// execute 메서드에서 동일한 코드 반복
if (!credentials.username || (credentials.username as string).trim() === '') {
throw new NodeOperationError(this.getNode(), 'Username is required');
}
// ❌ 문제 3: 헬퍼 함수가 execute() 내부에 정의
async execute(this: IExecuteFunctions) {
// 매 실행마다 함수가 재생성됨
const extractDomain = (email: string): string => {
const trimmed = email.trim().toLowerCase();
const atIndex = trimmed.lastIndexOf('@');
return atIndex > 0 ? trimmed.substring(atIndex + 1) : '';
};
// 매 실행마다 함수가 재생성됨
const validateEmailDomains = (emailsStr: string, fieldName: string): void => {
// ... 검증 로직
};
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
// ❌ 문제 4: Transporter가 루프 내에서 매번 생성
const transporter = nodemailer.createTransport(transportOptions);
// ❌ 문제 5: 반복적인 조건부 할당
if (textContent) {
mailOptions.text = textContent;
}
if (htmlContent) {
mailOptions.html = htmlContent;
}
if (options.ccEmail) {
mailOptions.cc = options.ccEmail;
}
// ... 계속 반복
}
}
영향 범위:
- 성능 저하: 매 아이템마다 새 SMTP 연결 생성
- 메모리 낭비: 함수가 매 실행마다 재생성
- 유지보수 어려움: 동일한 검증 로직이 여러 곳에 산재
- 타입 안전성 부재: 런타임 에러 위험 증가
2. 원인 분석
2.1 타입 정의 부재의 문제
TypeScript의 장점은 컴파일 타임에 오류를 잡을 수 있다는 것입니다. 하지만 IDataObject처럼 any를 허용하는 타입을 사용하면 이 장점이 사라집니다.
// n8n의 IDataObject 정의
interface IDataObject {
[key: string]: any; // ← 모든 키에 any 타입 허용
}
// 문제: 오타나 잘못된 타입도 컴파일 통과
const mailOptions: IDataObject = {
form: fromEmail, // ← 'from'을 'form'으로 오타 - 컴파일 에러 없음
too: toEmail, // ← 'to'를 'too'로 오타 - 컴파일 에러 없음
subject: 123, // ← string이어야 하는데 number - 컴파일 에러 없음
};
2.2 DRY 원칙 위반
DRY(Don't Repeat Yourself) 원칙이 지켜지지 않으면:
| 문제 | 결과 |
|---|---|
| 동일 로직 중복 | 버그 수정 시 여러 곳 수정 필요 |
| 불일치 위험 | 한 곳만 수정하고 다른 곳 누락 |
| 테스트 복잡도 증가 | 같은 로직을 여러 번 테스트 |
2.3 함수 스코프와 성능
JavaScript에서 함수 내부에 정의된 함수는 외부 함수가 호출될 때마다 새로 생성됩니다.
// ❌ 나쁜 예: 매번 새 함수 객체 생성
async execute() {
const helper = () => { /* ... */ }; // 매 호출마다 새 함수 생성
for (let i = 0; i < 1000; i++) {
helper(); // 같은 함수를 1000번 호출하지만, 함수 객체는 이미 1개만 존재
}
}
// 하지만 execute()가 10번 호출되면?
// helper 함수가 10번 새로 생성됨
2.4 Connection Pooling의 중요성
SMTP 연결은 비용이 큰 작업입니다:
1회 SMTP 연결 = TCP 핸드셰이크 + TLS 협상 + SMTP 인증
1000개의 이메일을 보낼 때:
| 방식 | 연결 횟수 | 대략적 시간 |
|---|---|---|
| 매번 새 연결 | 1000회 | ~30초 |
| Connection Pool | 5회 (재사용) | ~3초 |
3. 해결 방법
3.1 타입 인터페이스 정의
Before:
const mailOptions: IDataObject = {
from: fromEmail,
to: toEmail,
subject,
};
After:
// ✅ 명확한 타입 정의
type EmailFormat = 'text' | 'html' | 'both'; // ← 유니온 타입으로 제한
interface ValidatedCredentials {
username: string;
password: string;
}
interface Attachment {
filename: string;
content: Buffer;
contentType: string;
}
interface MailOptions {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
cc?: string;
bcc?: string;
replyTo?: string;
attachments?: Attachment[]; // ← 중첩 타입도 명확히
}
// 사용
const mailOptions: MailOptions = {
from: fromEmail,
to: toEmail,
subject,
// form: fromEmail, // ← 컴파일 에러! 오타 감지
};
효과:
- 오타 즉시 감지
- IDE 자동완성 지원
- 리팩토링 시 영향 범위 파악 용이
3.2 헬퍼 함수 모듈 레벨로 추출
Before:
async execute(this: IExecuteFunctions) {
// ❌ execute() 내부에 정의 - 매 호출마다 재생성
const extractDomain = (email: string): string => {
const trimmed = email.trim().toLowerCase();
const atIndex = trimmed.lastIndexOf('@');
return atIndex > 0 ? trimmed.substring(atIndex + 1) : '';
};
}
After:
// ✅ 모듈 레벨에 정의 - 한 번만 생성
function extractDomain(email: string): string {
const trimmed = email.trim().toLowerCase();
const atIndex = trimmed.lastIndexOf('@');
return atIndex > 0 ? trimmed.substring(atIndex + 1) : '';
}
function validateEmailDomains(emailsStr: string, fieldName: string): void {
const emails = emailsStr.split(',').map((e) => e.trim()).filter((e) => e.length > 0);
for (const email of emails) {
const domain = extractDomain(email);
if (!domain) {
throw new ApplicationError(`Invalid email format in ${fieldName}: ${email}`);
}
if (!ALLOWED_DOMAINS.includes(domain)) {
throw new ApplicationError(`Domain "${domain}" is not allowed`);
}
}
}
// 클래스 내부에서 사용
async execute(this: IExecuteFunctions) {
// 단순히 호출만
validateEmailDomains(toEmail, 'To Email');
}
효과:
- 메모리 효율성 향상
- 단위 테스트 용이
- 재사용성 증가
3.3 자격증명 검증 로직 통합
Before:
// ❌ employeeAuthTest에서
if (!credentials.username || (credentials.username as string).trim() === '') {
return { status: 'Error', message: 'Username is required' };
}
if (!credentials.password || (credentials.password as string).trim() === '') {
return { status: 'Error', message: 'Password is required' };
}
// ❌ execute에서 동일한 코드 반복
if (!credentials.username || (credentials.username as string).trim() === '') {
throw new NodeOperationError(this.getNode(), 'Username is required');
}
if (!credentials.password || (credentials.password as string).trim() === '') {
throw new NodeOperationError(this.getNode(), 'Password is required');
}
After:
// ✅ 검증 로직을 하나의 함수로 통합
function validateCredentials(credentials: ICredentialDataDecryptedObject): ValidatedCredentials {
const username = credentials.username;
const password = credentials.password;
if (typeof username !== 'string' || !username.trim()) {
throw new ApplicationError('Username is required'); // ← n8n 권장 에러 클래스
}
if (typeof password !== 'string' || !password.trim()) {
throw new ApplicationError('Password is required');
}
return { username: username.trim(), password }; // ← 검증된 값 반환
}
// employeeAuthTest에서 사용
async employeeAuthTest(credential: ICredentialsDecrypted) {
try {
const validated = validateCredentials(credential.data);
// 인증 진행...
} catch (error) {
return { status: 'Error', message: (error as Error).message };
}
}
// execute에서 사용
async execute(this: IExecuteFunctions) {
try {
const validated = validateCredentials(credentials);
// 실행 진행...
} catch (error) {
throw new NodeOperationError(this.getNode(), (error as Error).message);
}
}
효과:
- 검증 로직 일원화
- 타입 단언(
as string) 제거 - 에러 메시지 일관성 보장
3.4 Transporter를 루프 외부로 이동 + Connection Pooling
Before:
async execute(this: IExecuteFunctions) {
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
// ❌ 매 아이템마다 새 Transporter 생성
const transportOptions: SMTPTransport.Options = {
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
};
const transporter = nodemailer.createTransport(transportOptions);
await transporter.sendMail(mailOptions);
}
}
After:
import type SMTPPool from 'nodemailer/lib/smtp-pool'; // ← Pool 타입 import
async execute(this: IExecuteFunctions) {
// ✅ 루프 시작 전 한 번만 생성 + Connection Pooling
const transportOptions: SMTPPool.Options = {
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
pool: true, // ← Connection Pooling 활성화
maxConnections: 5, // ← 최대 동시 연결 수
};
const transporter = nodemailer.createTransport(transportOptions);
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
// Transporter 재사용
await transporter.sendMail(mailOptions);
}
}
효과:
- 연결 재사용으로 성능 10배 이상 향상
- 리소스 효율성 증가
- 대량 발송 시 안정성 향상
3.5 조건부 스프레드로 옵션 필드 처리 간소화
Before:
// ❌ 반복적인 조건부 할당
const mailOptions: IDataObject = {
from: fromEmail,
to: toEmail,
subject,
};
if (textContent) {
mailOptions.text = textContent;
}
if (htmlContent) {
mailOptions.html = htmlContent;
}
if (options.ccEmail) {
mailOptions.cc = options.ccEmail;
}
if (options.bccEmail) {
mailOptions.bcc = options.bccEmail;
}
if (options.replyTo) {
mailOptions.replyTo = options.replyTo;
}
After:
// ✅ 조건부 스프레드 연산자 사용
const mailOptions: MailOptions = {
from: fromEmail,
to: toEmail,
subject,
...(textContent && { text: textContent }), // ← 값이 있을 때만 추가
...(htmlContent && { html: htmlContent }),
...(options.ccEmail && { cc: options.ccEmail as string }),
...(options.bccEmail && { bcc: options.bccEmail as string }),
...(options.replyTo && { replyTo: options.replyTo as string }),
};
작동 원리:
// textContent가 truthy일 때
...(textContent && { text: textContent })
// → ...{ text: "Hello" }
// → { text: "Hello" }가 객체에 병합
// textContent가 falsy(undefined, null, '')일 때
...(textContent && { text: textContent })
// → ...false
// → 아무것도 병합되지 않음
효과:
- 코드 라인 수 60% 감소
- 가독성 향상
- 실수 가능성 감소
3.6 HTTP 응답 상태 코드 검증 추가
Before:
// ❌ HTTP 상태 코드 검증 없음
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const jsonData = JSON.parse(data); // ← 401, 500 응답도 파싱 시도
// ...
} catch {
resolve({ success: false, error: 'Invalid response format' });
}
});
});
After:
// ✅ HTTP 상태 코드 먼저 검증
const req = http.request(options, (res) => {
// 상태 코드 검증
if (res.statusCode !== 200) {
resolve({
success: false,
error: `Authentication failed: HTTP ${res.statusCode}`
});
return; // ← 조기 반환
}
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
// ...
} catch {
resolve({ success: false, error: 'Invalid response format' });
}
});
});
효과:
- 명확한 에러 메시지 (HTTP 401 vs 파싱 에러 구분)
- 불필요한 파싱 시도 방지
- 디버깅 용이성 향상
3.7 중복 속성 제거 (Credentials 파일)
Before:
// ❌ icon과 iconUrl 중복 정의
export class FixedSenderSmtpApi implements ICredentialType {
icon = {
light: 'file:icon.svg',
dark: 'file:icon.dark.svg',
} as const;
iconUrl = 'file:icon.svg'; // ← 중복!
}
After:
// ✅ icon만 유지 (light/dark 지원)
export class FixedSenderSmtpApi implements ICredentialType {
icon = {
light: 'file:icon.svg',
dark: 'file:icon.dark.svg',
} as const;
// iconUrl 제거
}
4. 핵심 개념 정리
| 패턴 | Before | After | 효과 |
|---|---|---|---|
| 타입 정의 | IDataObject (any) |
전용 인터페이스 | 컴파일 타임 오류 감지 |
| 함수 위치 | execute() 내부 | 모듈 레벨 | 메모리 효율성 + 테스트 용이성 |
| 검증 로직 | 2곳에서 중복 | 단일 헬퍼 함수 | 유지보수성 향상 |
| Transporter | 루프 내 생성 | 루프 외 + Pool | 성능 10배↑ |
| 옵션 할당 | if문 반복 | 조건부 스프레드 | 코드량 60%↓ |
| HTTP 검증 | 상태코드 무시 | 먼저 검증 | 명확한 에러 |
| 속성 정리 | 중복 정의 | 단일 정의 | 혼란 방지 |
5. 베스트 프랙티스
체크리스트
□ 타입 정의
□ any 대신 명확한 인터페이스 사용
□ 유니온 타입으로 허용값 제한
□ 중첩 타입도 별도 인터페이스로 정의
□ 코드 구조
□ 헬퍼 함수는 모듈 레벨에 정의
□ 재사용 가능한 로직은 별도 함수로 추출
□ DRY 원칙 준수 확인
□ 성능
□ 루프 내 불필요한 객체 생성 제거
□ Connection Pool 활용 검토
□ 조기 반환으로 불필요한 연산 방지
□ 에러 처리
□ HTTP 상태 코드 검증
□ 명확한 에러 메시지
□ 프레임워크 권장 에러 클래스 사용
n8n 노드 개발 시 권장사항
// ✅ ApplicationError 사용 (n8n 권장)
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
// 모듈 레벨 헬퍼에서
throw new ApplicationError('Validation failed');
// 노드 컨텍스트에서
throw new NodeOperationError(this.getNode(), 'Operation failed');
6. FAQ
Q: 조건부 스프레드와 if문 할당 중 어느 것이 더 좋은가요?
A: 상황에 따라 다릅니다.
| 상황 | 권장 방식 |
|---|---|
| 단순 할당 (3개 이상) | 조건부 스프레드 |
| 복잡한 로직 포함 | if문 |
| 타입 변환 필요 | if문 |
조건부 스프레드는 간결하지만, 복잡한 변환이 필요하면 if문이 더 명확합니다.
Q: Connection Pool의 maxConnections는 얼마가 적당한가요?
A: 일반적으로 5~10개가 권장됩니다.
const transportOptions: SMTPPool.Options = {
pool: true,
maxConnections: 5, // ← 동시 연결 5개
};
- 너무 적으면: 대기 시간 증가
- 너무 많으면: SMTP 서버 부하 + 연결 거부 위험
Q: 왜 SMTPTransport.Options 대신 SMTPPool.Options를 사용하나요?
A: pool: true 옵션은 SMTPPool.Options 타입에만 정의되어 있습니다.
// ❌ 타입 에러
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
const options: SMTPTransport.Options = {
pool: true, // ← 'pool' does not exist in type 'Options'
};
// ✅ 올바른 타입
import type SMTPPool from 'nodemailer/lib/smtp-pool';
const options: SMTPPool.Options = {
pool: true, // ← OK
maxConnections: 5,
};
Q: ApplicationError vs NodeOperationError 언제 사용하나요?
A:
| 상황 | 사용할 클래스 |
|---|---|
| 모듈 레벨 헬퍼 함수 | ApplicationError |
| 노드 컨텍스트 (this.getNode() 접근 가능) | NodeOperationError |
| 자격증명 테스트 | ApplicationError (catch 후 반환값으로 변환) |
Q: 타입 단언(as string)을 완전히 제거할 수 있나요?
A: 외부 라이브러리(n8n-workflow)의 타입 정의 한계로 완전히 제거하기 어렵습니다. 하지만 헬퍼 함수에서 한 번 검증하면 이후에는 단언 없이 사용 가능합니다.
// 헬퍼에서 한 번 검증
function validateCredentials(credentials: ICredentialDataDecryptedObject): ValidatedCredentials {
// 여기서 타입 체크 후 ValidatedCredentials 반환
}
// 이후에는 단언 없이 사용
const validated = validateCredentials(credentials);
await authenticateUser(validated.username, validated.password); // ← as string 불필요
7. 참고 자료
- n8n 공식 문서 - Creating Nodes
- Nodemailer - Pooled SMTP
- TypeScript Handbook - Utility Types
- MDN - Spread syntax