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. 참고 자료