Node.js 인터뷰 질문 62

질문: Node.js로 마이크로서비스 아키텍처를 구현하는 방법과 모놀리식 아키텍처와 비교했을 때의 장단점은 무엇인가요?

답변:

Node.js는 마이크로서비스 아키텍처를 구현하는 데 적합한 기술로, 그 경량성과 비동기 특성 덕분에 독립적인 서비스를 효율적으로 실행할 수 있습니다. 마이크로서비스 구현 방법과 모놀리식 아키텍처와의 비교를 살펴보겠습니다.

1. Node.js로 마이크로서비스 구현하기

1.1 기본 마이크로서비스 구조

각 마이크로서비스는 독립적인 Node.js 애플리케이션으로, 자체 데이터베이스와 비즈니스 로직을 가집니다.

// user-service/index.js
const express = require("express");
const mongoose = require("mongoose");
const app = express();

// 데이터베이스 연결
mongoose.connect("mongodb://localhost:27017/user-service");

// 사용자 모델
const User = mongoose.model("User", {
  username: String,
  email: String,
  createdAt: { type: Date, default: Date.now },
});

app.use(express.json());

// 사용자 API 엔드포인트
app.get("/users", async (req, res) => {
  const users = await User.find({});
  res.json(users);
});

app.post("/users", async (req, res) => {
  const user = new User(req.body);
  await user.save();
  res.status(201).json(user);
});

// 서비스 실행
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`사용자 서비스가 포트 ${PORT}에서 실행 중입니다`);
});

1.2 서비스 간 통신

마이크로서비스 간 통신에는 다양한 방법이 있습니다:

1.2.1 REST API 기반 통신
// order-service/index.js
const express = require("express");
const axios = require("axios");
const app = express();

app.use(express.json());

// 주문 생성 API
app.post("/orders", async (req, res) => {
  const { userId, products } = req.body;

  try {
    // 사용자 서비스 API 호출하여 사용자 검증
    const userResponse = await axios.get(
      `http://user-service:3001/users/${userId}`
    );
    const user = userResponse.data;

    if (!user) {
      return res.status(404).json({ error: "사용자를 찾을 수 없습니다" });
    }

    // 제품 서비스 API 호출하여 재고 확인
    const stockResponse = await axios.post(
      "http://product-service:3002/products/check-stock",
      { products }
    );

    if (!stockResponse.data.inStock) {
      return res.status(400).json({ error: "일부 제품의 재고가 부족합니다" });
    }

    // 주문 생성 로직...
    const order = { userId, products, status: "created" };

    // 주문 저장 후 응답
    res.status(201).json(order);
  } catch (error) {
    res.status(500).json({ error: "주문 생성 중 오류가 발생했습니다" });
  }
});

const PORT = process.env.PORT || 3003;
app.listen(PORT, () => {
  console.log(`주문 서비스가 포트 ${PORT}에서 실행 중입니다`);
});
1.2.2 메시지 큐 기반 통신
// notification-service/index.js
const amqp = require("amqplib");
const nodemailer = require("nodemailer");

async function startService() {
  // RabbitMQ 연결
  const connection = await amqp.connect("amqp://rabbitmq:5672");
  const channel = await connection.createChannel();

  // 큐 선언
  const queue = "order_created";
  await channel.assertQueue(queue, { durable: true });

  console.log("알림 서비스가 메시지를 기다리는 중...");

  // 메시지 소비
  channel.consume(queue, async (msg) => {
    const order = JSON.parse(msg.content.toString());

    console.log(`새 주문 수신: ${order.id}`);

    try {
      // 이메일 발송 로직
      await sendOrderConfirmationEmail(order);

      // 메시지 확인
      channel.ack(msg);
    } catch (error) {
      console.error("알림 전송 실패:", error);
    }
  });
}

async function sendOrderConfirmationEmail(order) {
  // 이메일 전송 로직 구현
  // ...
}

startService().catch(console.error);

메시지 발행 측:

// order-service에서 메시지 발행
const amqp = require("amqplib");

async function publishOrderCreatedEvent(order) {
  const connection = await amqp.connect("amqp://rabbitmq:5672");
  const channel = await connection.createChannel();

  const queue = "order_created";
  await channel.assertQueue(queue, { durable: true });

  channel.sendToQueue(queue, Buffer.from(JSON.stringify(order)), {
    persistent: true,
  });

  console.log(`주문 생성 이벤트 발행: ${order.id}`);

  setTimeout(() => {
    connection.close();
  }, 500);
}

1.3 API 게이트웨이 구현

// api-gateway/index.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();

// 서비스 디스커버리 (간단한 예시, 실제로는 Consul, etcd 등 사용)
const services = {
  users: "http://user-service:3001",
  products: "http://product-service:3002",
  orders: "http://order-service:3003",
};

// 인증 미들웨어
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization;

  if (!token) {
    return res.status(401).json({ error: "인증이 필요합니다" });
  }

  // 토큰 검증 로직...
  // 검증 성공 시 다음 미들웨어로 진행
  next();
};

// 서비스별 프록시 설정
app.use(
  "/api/users",
  authMiddleware,
  createProxyMiddleware({
    target: services.users,
    pathRewrite: { "^/api/users": "/users" },
    changeOrigin: true,
  })
);

app.use(
  "/api/products",
  createProxyMiddleware({
    target: services.products,
    pathRewrite: { "^/api/products": "/products" },
    changeOrigin: true,
  })
);

app.use(
  "/api/orders",
  authMiddleware,
  createProxyMiddleware({
    target: services.orders,
    pathRewrite: { "^/api/orders": "/orders" },
    changeOrigin: true,
  })
);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API 게이트웨이가 포트 ${PORT}에서 실행 중입니다`);
});

1.4 서비스 디스커버리와 로드 밸런싱

Node.js 마이크로서비스에서는 일반적으로 외부 도구를 활용합니다:

// 서비스 디스커버리를 위한 Consul 클라이언트 예시
const Consul = require("consul");
const consul = new Consul({
  host: "consul",
  port: 8500,
});

// 서비스 등록
function registerService() {
  const serviceId = `user-service-${
    process.env.POD_NAME || Math.random().toString(36).substr(2, 9)
  }`;

  consul.agent.service.register(
    {
      id: serviceId,
      name: "user-service",
      address: process.env.POD_IP || "localhost",
      port: parseInt(process.env.PORT || 3001),
      check: {
        http: `http://localhost:${process.env.PORT || 3001}/health`,
        interval: "10s",
        timeout: "5s",
      },
    },
    (err) => {
      if (err) throw err;
      console.log(`서비스가 Consul에 등록되었습니다: ${serviceId}`);

      // 애플리케이션 종료 시 서비스 등록 해제
      process.on("SIGINT", deregisterService.bind(null, serviceId));
      process.on("SIGTERM", deregisterService.bind(null, serviceId));
    }
  );
}

function deregisterService(serviceId) {
  consul.agent.service.deregister(serviceId, (err) => {
    if (err) throw err;
    console.log(`서비스 등록이 해제되었습니다: ${serviceId}`);
    process.exit();
  });
}

1.5 서킷 브레이커 패턴 구현

// order-service에서 서킷 브레이커 패턴 구현
const CircuitBreaker = require("opossum");

// 사용자 서비스 API 호출 함수
async function getUserById(userId) {
  return axios.get(`http://user-service:3001/users/${userId}`);
}

// 서킷 브레이커 설정
const breakerOptions = {
  failureThreshold: 50, // 50% 실패율에서 회로 오픈
  resetTimeout: 10000, // 10초 후 반개방 상태로 전환
  timeout: 3000, // 3초 타임아웃
  errorThresholdPercentage: 50,
};

// 사용자 서비스 API 호출에 서킷 브레이커 적용
const userServiceBreaker = new CircuitBreaker(getUserById, breakerOptions);

// 폴백 함수 설정
userServiceBreaker.fallback(async (userId) => {
  // 캐시된 사용자 데이터 반환 또는 기본값 제공
  return { data: { id: userId, name: "알 수 없음", isCached: true } };
});

// API 엔드포인트에서 사용
app.post("/orders", async (req, res) => {
  const { userId, products } = req.body;

  try {
    // 서킷 브레이커를 통한 사용자 서비스 호출
    const userResponse = await userServiceBreaker.fire(userId);
    const user = userResponse.data;

    // 나머지 주문 생성 로직...
  } catch (error) {
    res.status(500).json({ error: "주문 생성 실패: " + error.message });
  }
});

// 서킷 브레이커 이벤트 처리
userServiceBreaker.on("open", () => {
  console.log(
    "사용자 서비스 서킷 브레이커가 열렸습니다. 서비스가 중단되었습니다."
  );
});

userServiceBreaker.on("halfOpen", () => {
  console.log(
    "사용자 서비스 서킷 브레이커가 반개방 상태입니다. 복구를 시도합니다."
  );
});

userServiceBreaker.on("close", () => {
  console.log(
    "사용자 서비스 서킷 브레이커가 닫혔습니다. 서비스가 정상 작동합니다."
  );
});

2. 마이크로서비스 vs 모놀리식 아키텍처

2.1 마이크로서비스 아키텍처의 장점

  1. 확장성: 개별 서비스를 독립적으로 확장 가능

    // 주문 서비스 확장 예시 (Kubernetes 환경)
    // kubectl scale deployment order-service --replicas=5
    
  2. 기술 다양성: 서비스별로 다른 기술 스택 사용 가능

    // 사용자 서비스: MongoDB + Express
    // 결제 서비스: PostgreSQL + Fastify
    const fastify = require("fastify");
    const app = fastify();
    
    app.post("/payments", async (request, reply) => {
      // 결제 처리 로직...
      return { success: true };
    });
    
  3. 장애 격리: 한 서비스의 실패가 전체 시스템에 영향을 미치지 않음

  4. 개발 및 배포 독립성: 팀별로 독립적인 개발과 배포 가능

    // 각 서비스별 CI/CD 파이프라인 구성 (예: GitLab CI .gitlab-ci.yml)
    // stages:
    //   - test
    //   - build
    //   - deploy
    
  5. 코드베이스 단순화: 각 서비스의 코드가 작고 집중적

2.2 마이크로서비스 아키텍처의 단점

  1. 분산 시스템 복잡성: 서비스 간 통신, 데이터 일관성 등의 문제

  2. 운영 복잡성 증가: 여러 서비스 배포, 모니터링, 로깅의 어려움

    // 중앙 집중식 로깅 예시 (Winston + ELK 스택)
    const winston = require("winston");
    
    const logger = winston.createLogger({
      level: "info",
      format: winston.format.json(),
      defaultMeta: { service: "user-service" },
      transports: [
        new winston.transports.File({ filename: "error.log", level: "error" }),
        new winston.transports.File({ filename: "combined.log" }),
        // Elasticsearch 전송을 위한 transport 추가 가능
      ],
    });
    
  3. 네트워크 지연 및 오버헤드: 서비스 간 통신에 따른 성능 저하

  4. 디버깅 어려움: 여러 서비스에 걸친 요청 추적이 복잡

    // 분산 추적을 위한 OpenTelemetry 사용 예시
    const opentelemetry = require("@opentelemetry/api");
    const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
    const {
      registerInstrumentations,
    } = require("@opentelemetry/instrumentation");
    const {
      ExpressInstrumentation,
    } = require("@opentelemetry/instrumentation-express");
    const {
      HttpInstrumentation,
    } = require("@opentelemetry/instrumentation-http");
    
    // 추적 설정
    const provider = new NodeTracerProvider();
    provider.register();
    
    registerInstrumentations({
      instrumentations: [
        new ExpressInstrumentation(),
        new HttpInstrumentation(),
      ],
    });
    
  5. 초기 개발 오버헤드: 아키텍처 설계 및 인프라 구축에 시간 소요

2.3 모놀리식 아키텍처의 장점

  1. 개발 단순성: 단일 코드베이스, 쉬운 개발 환경 설정

  2. 성능: 서비스 간 통신 없음, 함수 호출 기반으로 빠름

  3. 트랜잭션 처리 용이: 단일 데이터베이스로 ACID 트랜잭션 지원

    // 모놀리식에서의 트랜잭션 예시
    async function createOrderWithPayment(orderData, paymentData) {
      const session = await mongoose.startSession();
      session.startTransaction();
    
      try {
        const order = await Order.create([orderData], { session });
        paymentData.orderId = order[0]._id;
        await Payment.create([paymentData], { session });
    
        await session.commitTransaction();
        return order[0];
      } catch (error) {
        await session.abortTransaction();
        throw error;
      } finally {
        session.endSession();
      }
    }
    
  4. 배포 단순성: 단일 애플리케이션 배포

  5. 모니터링 및 디버깅 용이: 단일 시스템에서 로그 및 오류 추적

2.4 모놀리식 아키텍처의 단점

  1. 확장성 제한: 전체 애플리케이션을 함께 확장해야 함

  2. 코드베이스 복잡성: 규모가 커질수록 코드 관리 어려움

  3. 기술 제약: 단일 기술 스택에 종속

  4. 탄력성 부족: 한 부분의 오류가 전체 시스템에 영향

  5. 배포 위험성: 작은 변경사항도 전체 시스템 재배포 필요

3. 마이크로서비스를 위한 클라우드 네이티브 접근법

Node.js 마이크로서비스는 클라우드 네이티브 환경에서 특히 효과적입니다:

3.1 컨테이너화 (Docker)

# Dockerfile for user-service
FROM node:16-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3001
CMD ["node", "index.js"]

3.2 오케스트레이션 (Kubernetes)

# 사용자 서비스를 위한 Kubernetes 배포 설정
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
    spec:
      containers:
        - name: user-service
          image: user-service:1.0.0
          ports:
            - containerPort: 3001
          env:
            - name: MONGODB_URI
              valueFrom:
                secretKeyRef:
                  name: user-service-secrets
                  key: mongodb-uri
          readinessProbe:
            httpGet:
              path: /health
              port: 3001

3.3 서비스 메시 (Istio)

Istio와 같은 서비스 메시를 사용하면 트래픽 관리, 보안, 관찰성을 향상시킬 수 있습니다.

# Istio VirtualService 예시
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: user-service
spec:
  hosts:
    - user-service
  http:
    - route:
        - destination:
            host: user-service
            subset: v1
          weight: 90
        - destination:
            host: user-service
            subset: v2
          weight: 10

4. 마이크로서비스 설계 원칙

4.1 도메인 주도 설계 (DDD)

// 주문 서비스의 도메인 모델 예시
class Order {
  constructor(id, customerId, items, status = "pending") {
    this.id = id;
    this.customerId = customerId;
    this.items = items;
    this.status = status;
    this.createdAt = new Date();
    this.total = this.calculateTotal();
  }

  calculateTotal() {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }

  ship() {
    if (this.status !== "paid") {
      throw new Error("결제가 완료되지 않은 주문은 배송할 수 없습니다");
    }
    this.status = "shipped";
    return new OrderShippedEvent(this.id, this.customerId);
  }
}

// 도메인 이벤트
class OrderShippedEvent {
  constructor(orderId, customerId) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.occurredAt = new Date();
  }
}

4.2 API 게이트웨이 패턴

API 게이트웨이는 클라이언트와 마이크로서비스 간의 중개자 역할을 합니다. Express Gateway나 Netflix Zuul 등의 도구를 사용할 수 있습니다.

4.3 CQRS (명령 쿼리 책임 분리)

// CQRS 패턴 예시
// 명령 처리기 (Command Handler)
async function createOrderHandler(command) {
  const { customerId, items } = command;

  // 비즈니스 로직 및 유효성 검사
  if (!items || items.length === 0) {
    throw new Error("주문 항목이 비어 있습니다");
  }

  // 주문 생성
  const order = new Order(uuid(), customerId, items);

  // 이벤트 저장소에 저장
  await eventStore.save("order", order.id, [
    { type: "OrderCreated", data: order },
  ]);

  return order.id;
}

// 쿼리 처리기 (Query Handler)
async function getOrderHandler(query) {
  const { orderId } = query;

  // 읽기 모델에서 데이터 조회
  const order = await orderReadModel.findById(orderId);

  if (!order) {
    throw new Error("주문을 찾을 수 없습니다");
  }

  return order;
}

4.4 이벤트 소싱 (Event Sourcing)

// 이벤트 소싱 구현 예시
class OrderAggregate {
  constructor(id) {
    this.id = id;
    this.events = [];
    this.state = { items: [], status: "new" };
  }

  // 이벤트 적용
  apply(event) {
    this.events.push(event);
    this.updateState(event);
  }

  // 상태 업데이트
  updateState(event) {
    switch (event.type) {
      case "OrderCreated":
        this.state = { ...event.data };
        break;
      case "ItemAdded":
        this.state.items.push(event.data);
        break;
      case "OrderPaid":
        this.state.status = "paid";
        this.state.paidAt = event.occurredAt;
        break;
    }
  }

  // 이벤트 저장 및 발행
  async save() {
    for (const event of this.events) {
      await eventStore.append(this.id, event);
      await eventBus.publish(event);
    }
    this.events = [];
  }

  // 이벤트 스트림에서 집계 객체 재구성
  static async load(id) {
    const aggregate = new OrderAggregate(id);
    const events = await eventStore.getEvents(id);

    for (const event of events) {
      aggregate.updateState(event);
    }

    return aggregate;
  }
}

요약

Node.js는 마이크로서비스 구현에 매우 적합한 기술로, 경량 서비스 개발에 효과적입니다. 마이크로서비스 아키텍처는 확장성, 독립적 배포, 기술 다양성 등의 장점을 제공하지만, 분산 시스템 관리의 복잡성과 네트워크 오버헤드 등의 단점이 있습니다.

반면 모놀리식 아키텍처는 개발 단순성, 성능, 트랜잭션 처리의 용이성 등의 장점이 있지만, 대규모 애플리케이션으로 성장할 때 확장성 및 유지보수 면에서 제한이 있습니다.

적절한 아키텍처를 선택하려면 프로젝트의 규모, 팀 구조, 개발 속도, 확장성 요구사항, 그리고 운영 리소스를 고려해야 합니다. 작은 프로젝트는 모놀리식으로 시작하여 필요에 따라 점진적으로 마이크로서비스로 전환하는 전략을 고려할 수 있습니다.

results matching ""

    No results matching ""