Node.js 인터뷰 질문 64

질문: Node.js 애플리케이션의 성능을 최적화하는 방법에는 어떤 것들이 있나요?

답변:

Node.js 애플리케이션의 성능 최적화는 확장성 있는 애플리케이션을 구축하는 데 매우 중요합니다. 다양한 레벨에서 성능을 개선할 수 있는 방법을 살펴보겠습니다.

1. 코드 최적화

1.1 비동기 프로그래밍 활용

Node.js의 주요 장점 중 하나는 비동기 이벤트 기반 아키텍처입니다. 이를 최대한 활용해야 합니다.

비효율적인 코드:

const fs = require("fs");

// 동기식 파일 읽기 - 이벤트 루프 차단
function readFileSync(filename) {
  const content = fs.readFileSync(filename, "utf8");
  return content;
}

// 순차적 처리로 비효율적
function processFiles(files) {
  const results = [];
  for (const file of files) {
    const content = readFileSync(file);
    results.push(content);
  }
  return results;
}

최적화된 코드:

const fs = require("fs").promises;

// 비동기식 파일 읽기
async function readFile(filename) {
  return await fs.readFile(filename, "utf8");
}

// Promise.all을 사용한 병렬 처리
async function processFiles(files) {
  const promises = files.map((file) => readFile(file));
  return await Promise.all(promises);
}

1.2 메모리 관리 최적화

// 메모리 누수를 일으킬 수 있는 코드
const cache = {};

function fetchDataWithLeak(key) {
  if (!cache[key]) {
    // 캐시 항목에 대한 참조가 유지되어 메모리 누수 가능성
    cache[key] = fetchDataFromDatabase(key);
  }
  return cache[key];
}

// 최적화된 메모리 관리
const NodeCache = require("node-cache");
const dataCache = new NodeCache({ stdTTL: 600, checkperiod: 120 });

function fetchDataOptimized(key) {
  let data = dataCache.get(key);
  if (data === undefined) {
    data = fetchDataFromDatabase(key);
    dataCache.set(key, data);
  }
  return data;
}

1.3 V8 엔진 최적화 활용

// 비효율적인 객체 접근
function processUserData(users) {
  const result = [];
  for (let i = 0; i < users.length; i++) {
    const user = users[i];
    if (user.active === true) {
      result.push({
        name: user.name,
        email: user.email,
        lastLogin: user.lastLogin,
      });
    }
  }
  return result;
}

// 객체 구조의 일관성 유지로 V8 최적화 활용
function processUserDataOptimized(users) {
  // 같은 구조의 객체를 사용하여 V8 히든 클래스 최적화 활용
  return users
    .filter((user) => user.active === true)
    .map((user) => ({
      name: user.name,
      email: user.email,
      lastLogin: user.lastLogin,
    }));
}

2. 데이터베이스 최적화

2.1 효율적인 쿼리 작성 및 인덱싱

// 데이터베이스 쿼리 최적화 (MongoDB 예제)
async function findUsersByStatus(status) {
  // 인덱스가 있는 필드 사용
  const users = await User.find({ status })
    .select("name email lastLogin") // 필요한 필드만 선택
    .lean() // 몽구스 문서 대신 일반 JS 객체 반환
    .limit(100);

  return users;
}

// 데이터베이스 설정 (인덱스 생성)
const UserSchema = new mongoose.Schema({
  name: String,
  email: String,
  status: String,
  lastLogin: Date,
  // ... 기타 필드
});

// 자주 쿼리하는 필드에 인덱스 추가
UserSchema.index({ status: 1 });
UserSchema.index({ email: 1 }, { unique: true });

2.2 연결 풀링 사용

// MySQL 연결 풀링 예제
const mysql = require("mysql2");

const pool = mysql.createPool({
  host: "localhost",
  user: "user",
  password: "password",
  database: "mydb",
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0,
});

// 프로미스 래퍼 사용
const promisePool = pool.promise();

async function getUserById(id) {
  const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [
    id,
  ]);
  return rows[0];
}

2.3 배치 작업 및 트랜잭션 활용

// 배치 삽입 (MongoDB 예제)
async function insertManyUsers(users) {
  // 개별 삽입보다 배치 삽입이 훨씬 효율적
  return await User.insertMany(users);
}

// 트랜잭션 사용 (MySQL 예제)
async function transferFunds(fromAccountId, toAccountId, amount) {
  const connection = await promisePool.getConnection();

  try {
    await connection.beginTransaction();

    // 출금 계좌에서 금액 차감
    await connection.query(
      "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
      [amount, fromAccountId, amount]
    );

    const [result] = await connection.query(
      "SELECT ROW_COUNT() as affectedRows"
    );

    if (result[0].affectedRows === 0) {
      throw new Error("잔액 부족 또는 계좌를 찾을 수 없음");
    }

    // 입금 계좌에 금액 추가
    await connection.query(
      "UPDATE accounts SET balance = balance + ? WHERE id = ?",
      [amount, toAccountId]
    );

    await connection.commit();
    return true;
  } catch (err) {
    await connection.rollback();
    throw err;
  } finally {
    connection.release();
  }
}

3. 캐싱 구현

3.1 메모리 내 캐싱

const NodeCache = require("node-cache");
const cache = new NodeCache({ stdTTL: 300 }); // 5분 TTL

async function getUserWithCache(id) {
  const cacheKey = `user:${id}`;

  // 캐시에서 사용자 조회
  const cachedUser = cache.get(cacheKey);
  if (cachedUser) {
    return cachedUser;
  }

  // 캐시에 없으면 데이터베이스에서 조회
  const user = await User.findById(id);

  // 캐시에 저장
  if (user) {
    cache.set(cacheKey, user);
  }

  return user;
}

3.2 Redis 캐싱

const redis = require("redis");
const { promisify } = require("util");
const client = redis.createClient();

// Redis 메서드를 프로미스화
const getAsync = promisify(client.get).bind(client);
const setexAsync = promisify(client.setex).bind(client);

async function getProductWithRedisCache(id) {
  const cacheKey = `product:${id}`;

  try {
    // Redis에서 데이터 조회
    const cachedData = await getAsync(cacheKey);
    if (cachedData) {
      return JSON.parse(cachedData);
    }

    // 캐시에 없으면 데이터베이스에서 조회
    const product = await Product.findById(id);

    // Redis에 저장 (60초 TTL)
    if (product) {
      await setexAsync(cacheKey, 60, JSON.stringify(product));
    }

    return product;
  } catch (err) {
    console.error("Redis 캐싱 오류:", err);
    // 오류 발생 시 DB에서 직접 조회
    return await Product.findById(id);
  }
}

3.3 데이터베이스 쿼리 결과 캐싱

// Mongoose에서 쿼리 캐싱
const mongoose = require("mongoose");
const cachegoose = require("cachegoose");

cachegoose(mongoose, {
  engine: "redis",
  port: 6379,
  host: "localhost",
});

async function getPopularProducts() {
  // 쿼리 결과를 10분 동안 캐싱
  const products = await Product.find({ featured: true })
    .sort("-views")
    .limit(10)
    .cache(600)
    .exec();

  return products;
}

4. HTTP 최적화

4.1 압축 사용

const express = require("express");
const compression = require("compression");
const app = express();

// 응답 압축 활성화
app.use(
  compression({
    // 1KB 이상 크기의 응답만 압축
    threshold: 1024,
    // 이미지, 비디오 등 이미 압축된 형식은 제외
    filter: (req, res) => {
      const contentType = res.getHeader("Content-Type");
      return !/image|video|audio/.test(contentType);
    },
  })
);

4.2 적절한 HTTP 캐싱 설정

// 정적 자산 캐싱
app.use(
  "/static",
  express.static("public", {
    maxAge: "1d", // 1일 동안 클라이언트 캐싱
    etag: true,
    lastModified: true,
  })
);

// API 응답 캐싱 헤더 설정
function setCacheHeaders(req, res, next) {
  // 공개 콘텐츠에 대한 캐싱 설정
  res.set("Cache-Control", "public, max-age=300"); // 5분
  next();
}

app.get("/api/products", setCacheHeaders, getProducts);

4.3 HTTP/2 사용

const http2 = require("http2");
const fs = require("fs");
const express = require("express");
const app = express();

// Express 애플리케이션 설정
// ...

// HTTP/2 서버 생성
const server = http2.createSecureServer(
  {
    key: fs.readFileSync("server.key"),
    cert: fs.readFileSync("server.crt"),
  },
  app
);

server.listen(443, () => {
  console.log("HTTP/2 서버가 포트 443에서 실행 중입니다");
});

5. 클러스터링 및 로드 밸런싱

5.1 Node.js 클러스터 모듈 사용

const cluster = require("cluster");
const http = require("http");
const numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  console.log(`마스터 프로세스 ${process.pid} 실행 중`);

  // CPU 코어 수만큼 워커 생성
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
    // 워커가 종료되면 새로운 워커 생성
    cluster.fork();
  });
} else {
  // 워커 프로세스는 HTTP 서버 생성
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end(`워커 ${process.pid}가 응답함\n`);
    })
    .listen(8000);

  console.log(`워커 ${process.pid} 시작됨`);
}

5.2 PM2 사용

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "app",
      script: "./app.js",
      instances: "max", // CPU 코어 수만큼 인스턴스 생성
      exec_mode: "cluster", // 클러스터 모드
      watch: true, // 파일 변경 시 자동 재시작
      max_memory_restart: "1G", // 1GB 메모리 사용 시 재시작
      env: {
        NODE_ENV: "development",
      },
      env_production: {
        NODE_ENV: "production",
      },
    },
  ],
};

6. 스트리밍 및 버퍼링

6.1 파일 스트리밍

const fs = require("fs");
const zlib = require("zlib");
const http = require("http");

// 비효율적인 방법: 전체 파일을 메모리에 로드
http
  .createServer((req, res) => {
    if (req.url === "/download") {
      fs.readFile("large-file.txt", (err, data) => {
        if (err) throw err;
        res.end(data);
      });
    }
  })
  .listen(3000);

// 스트리밍을 사용한 효율적인 방법
http
  .createServer((req, res) => {
    if (req.url === "/download-stream") {
      // 압축 및 스트리밍
      res.setHeader("Content-Encoding", "gzip");
      fs.createReadStream("large-file.txt").pipe(zlib.createGzip()).pipe(res);
    }
  })
  .listen(3001);

6.2 데이터베이스 스트리밍

// MongoDB 커서 스트리밍
function streamLargeCollection(req, res) {
  const cursor = Product.find().cursor();

  res.setHeader("Content-Type", "application/json");
  res.write("[");

  let isFirst = true;

  cursor.on("data", (doc) => {
    if (!isFirst) {
      res.write(",");
    } else {
      isFirst = false;
    }
    res.write(JSON.stringify(doc));
  });

  cursor.on("end", () => {
    res.write("]");
    res.end();
  });

  cursor.on("error", (err) => {
    res.status(500).json({ error: err.message });
  });
}

7. 프로파일링 및 모니터링

7.1 프로파일링 도구 사용

// NODE_ENV=production node --prof app.js
// 실행 후 생성된 isolate-xxxxx-vvvvv-mmmmm.log 파일 분석:
// node --prof-process isolate-xxxxx-vvvvv-mmmmm.log > processed.txt

// 메모리 사용량 스냅샷 생성
const heapdump = require("heapdump");

// 10초마다 스냅샷 생성
setInterval(() => {
  const filename = `heapdump-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(filename, (err) => {
    if (err) console.error(err);
    else console.log(`Heap snapshot written to ${filename}`);
  });
}, 10000);

7.2 APM 도구 통합

// New Relic APM 통합
const newrelic = require("newrelic");
const express = require("express");
const app = express();

app.get("/api/users", async (req, res) => {
  // 사용자 정의 트랜잭션 세그먼트 생성
  newrelic
    .startSegment("fetch-users-db", true, async () => {
      const users = await User.find();
      return users;
    })
    .then((users) => {
      res.json(users);
    })
    .catch((err) => {
      newrelic.noticeError(err);
      res.status(500).json({ error: err.message });
    });
});

8. 외부 서비스 통합 최적화

8.1 연결 풀링 및 재사용

const axios = require("axios");
const https = require("https");

// 지속적인 연결을 위한 에이전트 설정
const agent = new https.Agent({
  keepAlive: true,
  maxSockets: 50,
  maxFreeSockets: 10,
  timeout: 60000,
  freeSocketTimeout: 30000,
});

// Axios 인스턴스 생성과 설정
const api = axios.create({
  baseURL: "https://api.example.com",
  httpsAgent: agent,
  timeout: 5000,
});

// 재사용 가능한 API 클라이언트
async function fetchUserData(userId) {
  try {
    const response = await api.get(`/users/${userId}`);
    return response.data;
  } catch (err) {
    console.error("API 요청 실패:", err);
    throw err;
  }
}

8.2 서킷 브레이커 패턴 구현

const CircuitBreaker = require("opossum");

// API 호출 함수
function callExternalAPI() {
  return axios.get("https://api.example.com/data");
}

// 서킷 브레이커 구성
const breaker = new CircuitBreaker(callExternalAPI, {
  timeout: 3000, // 3초 타임아웃
  errorThresholdPercentage: 50, // 50% 실패율에서 서킷 오픈
  resetTimeout: 30000, // 30초 후 반개방 상태로 전환
});

// 폴백 처리
breaker.fallback(() => {
  return { data: { fallback: true, message: "서비스 일시적으로 사용 불가" } };
});

// API 요청 처리
app.get("/api/external-data", async (req, res) => {
  try {
    const result = await breaker.fire();
    res.json(result.data);
  } catch (err) {
    res.status(500).json({ error: "외부 API 호출 실패" });
  }
});

// 이벤트 처리
breaker.on("open", () => console.log("서킷 브레이커 열림"));
breaker.on("close", () => console.log("서킷 브레이커 닫힘"));
breaker.on("halfOpen", () => console.log("서킷 브레이커 반개방 상태"));

9. 작업 큐 활용

9.1 배경 작업 처리

const Queue = require("bull");
const nodemailer = require("nodemailer");

// 이메일 전송 큐 생성
const emailQueue = new Queue("email-queue", {
  redis: {
    host: "localhost",
    port: 6379,
  },
});

// 이메일 전송 작업 생산자
function sendWelcomeEmail(user) {
  emailQueue.add(
    {
      to: user.email,
      subject: "환영합니다!",
      template: "welcome",
      context: {
        name: user.name,
      },
    },
    {
      attempts: 3, // 재시도 횟수
      backoff: {
        type: "exponential",
        delay: 5000, // 초기 지연 시간 (5초)
      },
    }
  );
}

// 이메일 전송 작업 소비자
emailQueue.process(async (job) => {
  const { to, subject, template, context } = job.data;

  const transporter = nodemailer.createTransport({
    // 이메일 서버 설정
  });

  // 이메일 전송
  return transporter.sendMail({
    from: "no-reply@example.com",
    to,
    subject,
    template,
    context,
  });
});

// 오류 처리
emailQueue.on("failed", (job, err) => {
  console.error(`작업 ${job.id} 실패:`, err);
});

9.2 작업 스케줄링

const Queue = require("bull");

// 정기 작업 큐 생성
const scheduledTasksQueue = new Queue("scheduled-tasks", {
  redis: { host: "localhost", port: 6379 },
});

// 매일 자정에 실행되는 정리 작업 스케줄링
scheduledTasksQueue.add(
  "cleanup-expired-data",
  {},
  {
    repeat: {
      cron: "0 0 * * *", // 매일 자정
    },
  }
);

// 주기적으로 실행되는 메트릭 업데이트
scheduledTasksQueue.add(
  "update-metrics",
  {},
  {
    repeat: {
      every: 60 * 60 * 1000, // 1시간마다
    },
  }
);

// 작업 처리
scheduledTasksQueue.process("cleanup-expired-data", async () => {
  console.log("만료된 데이터 정리 중...");
  // 오래된 레코드 삭제 로직
  await ExpiredData.deleteMany({ expiresAt: { $lt: new Date() } });
});

scheduledTasksQueue.process("update-metrics", async () => {
  console.log("메트릭 업데이트 중...");
  // 애플리케이션 메트릭 계산 및 저장
});

10. 네이티브 모듈 및 작업자 스레드

10.1 CPU 집약적 작업에 네이티브 모듈 사용

// 네이티브 애드온 사용 예시
const nativeModule = require("./build/Release/native_module");

app.get("/process-image", (req, res) => {
  const inputPath = req.query.path;

  try {
    // 네이티브 C++ 모듈에서 이미지 처리
    const result = nativeModule.processImage(inputPath);
    res.json({ success: true, result });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

10.2 작업자 스레드 사용

const { Worker } = require("worker_threads");

// CPU 집약적 작업을 별도 스레드에서 처리
function runHeavyTask(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./workers/heavy-task.js", {
      workerData: data,
    });

    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) {
        reject(new Error(`워커가 종료 코드 ${code}와 함께 종료됨`));
      }
    });
  });
}

// API 엔드포인트
app.post("/process-data", async (req, res) => {
  try {
    const result = await runHeavyTask(req.body);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// heavy-task.js (워커 파일)
const { workerData, parentPort } = require("worker_threads");

// CPU 집약적인 작업 수행
function performHeavyComputation(data) {
  // 복잡한 계산 수행...
  return { result: data.value * 2 };
}

// 계산 결과를 메인 스레드로 전송
const result = performHeavyComputation(workerData);
parentPort.postMessage(result);

요약

Node.js 애플리케이션의 성능을 최적화하기 위한 주요 방법은 다음과 같습니다:

  1. 코드 최적화: 비동기 프로그래밍 활용, 메모리 관리 개선, V8 엔진 최적화 활용

  2. 데이터베이스 최적화: 효율적인 쿼리 작성, 인덱싱, 연결 풀링, 배치 작업 사용

  3. 캐싱 구현: 메모리 내 캐싱, Redis 활용, 쿼리 결과 캐싱

  4. HTTP 최적화: 응답 압축, 적절한 HTTP 캐싱 설정, HTTP/2 사용

  5. 클러스터링 및 로드 밸런싱: Node.js 클러스터 모듈 또는 PM2 활용

  6. 스트리밍 및 버퍼링: 파일 및 데이터베이스 스트리밍 활용

  7. 프로파일링 및 모니터링: 성능 병목 현상 식별 및 해결, APM 도구 통합

  8. 외부 서비스 통합 최적화: 연결 풀링, 서킷 브레이커 패턴 구현

  9. 작업 큐 활용: 백그라운드 작업 및 작업 스케줄링

  10. 네이티브 모듈 및 작업자 스레드: CPU 집약적 작업 처리

이러한 최적화 방법을 적용할 때는 항상 실제 측정을 통해 성능 향상을 확인하고, 특정 애플리케이션에 맞게 조정하는 것이 중요합니다. 모든 최적화는 복잡성을 증가시킬 수 있으므로, 각 기술의 장단점을 고려하여 필요한 곳에 적절히 적용해야 합니다.

results matching ""

    No results matching ""