Node.js 인터뷰 질문 27
질문: Node.js로 마이크로서비스 아키텍처를 구현하는 방법과 장단점을 설명해주세요.
답변:
마이크로서비스 아키텍처는 하나의 큰 애플리케이션을 여러 개의 작은, 독립적인 서비스로 분리하여 구축하는 소프트웨어 개발 접근 방식입니다. Node.js는 가볍고, 비동기 I/O에 최적화되어 있으며, 빠른 개발 속도를 제공하기 때문에 마이크로서비스 구현에 적합한 기술입니다.
Node.js로 마이크로서비스 구현하기
1. 서비스 설계
각 마이크로서비스는 특정 비즈니스 기능을 담당하며, 자체 데이터베이스를 가질 수 있습니다.
// 사용자 서비스 (user-service.js)
const express = require("express");
const mongoose = require("mongoose");
const app = express();
// 데이터베이스 연결
mongoose.connect("mongodb://localhost:27017/user-service", {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// 사용자 모델
const User = mongoose.model("User", {
name: String,
email: String,
password: String,
});
app.use(express.json());
// 사용자 조회 API
app.get("/users/:id", async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user)
return res.status(404).json({ message: "사용자를 찾을 수 없습니다." });
res.json(user);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// 사용자 생성 API
app.post("/users", async (req, res) => {
try {
const user = new User(req.body);
await user.save();
res.status(201).json(user);
} catch (error) {
res.status(400).json({ message: error.message });
}
});
app.listen(3001, () => {
console.log("사용자 서비스가 3001 포트에서 실행 중입니다.");
});
// 주문 서비스 (order-service.js)
const express = require("express");
const mongoose = require("mongoose");
const axios = require("axios");
const app = express();
// 데이터베이스 연결
mongoose.connect("mongodb://localhost:27017/order-service", {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// 주문 모델
const Order = mongoose.model("Order", {
userId: String,
products: [{ productId: String, quantity: Number }],
totalAmount: Number,
status: String,
createdAt: { type: Date, default: Date.now },
});
app.use(express.json());
// 주문 생성 API
app.post("/orders", async (req, res) => {
try {
// 사용자 서비스에서 사용자 정보 확인
const userResponse = await axios.get(
`http://localhost:3001/users/${req.body.userId}`
);
if (!userResponse.data) {
return res.status(404).json({ message: "사용자를 찾을 수 없습니다." });
}
// 제품 서비스에서 제품 정보 및 재고 확인 (예시)
// ...
const order = new Order(req.body);
await order.save();
res.status(201).json(order);
} catch (error) {
res.status(400).json({ message: error.message });
}
});
// 주문 조회 API
app.get("/orders/:id", async (req, res) => {
try {
const order = await Order.findById(req.params.id);
if (!order)
return res.status(404).json({ message: "주문을 찾을 수 없습니다." });
res.json(order);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
app.listen(3002, () => {
console.log("주문 서비스가 3002 포트에서 실행 중입니다.");
});
2. API 게이트웨이 구현
API 게이트웨이는 클라이언트의 요청을 적절한 마이크로서비스로 라우팅하는 역할을 합니다.
// api-gateway.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();
// 사용자 서비스로 라우팅
app.use(
"/users",
createProxyMiddleware({
target: "http://localhost:3001",
changeOrigin: true,
})
);
// 주문 서비스로 라우팅
app.use(
"/orders",
createProxyMiddleware({
target: "http://localhost:3002",
changeOrigin: true,
})
);
// 제품 서비스로 라우팅
app.use(
"/products",
createProxyMiddleware({
target: "http://localhost:3003",
changeOrigin: true,
})
);
app.listen(3000, () => {
console.log("API 게이트웨이가 3000 포트에서 실행 중입니다.");
});
3. 서비스 디스커버리
서비스 디스커버리는 서비스의 위치(IP 주소, 포트)를 동적으로 찾는 메커니즘입니다. Node.js에서는 Consul, etcd, ZooKeeper 등을 사용할 수 있습니다.
// Consul을 사용한 서비스 등록 예시
const Consul = require("consul");
const consul = new Consul();
// 서비스 등록
consul.agent.service.register(
{
name: "user-service",
id: "user-service-1",
address: "localhost",
port: 3001,
check: {
http: "http://localhost:3001/health",
interval: "10s",
},
},
(err) => {
if (err) throw err;
console.log("사용자 서비스가 Consul에 등록되었습니다.");
}
);
// 서비스 검색
consul.catalog.service.nodes("user-service", (err, result) => {
if (err) throw err;
console.log("사용자 서비스 노드:", result);
});
4. 서비스 간 통신
마이크로서비스는 REST API, gRPC, 또는 메시지 큐를 통해 통신할 수 있습니다.
REST API 통신 예시 (위에서 이미 사용됨)
gRPC 통신 예시
// user-service.proto
syntax = "proto3";
service UserService {
rpc GetUser (GetUserRequest) returns (User) {}
}
message GetUserRequest {
string id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
// gRPC 사용자 서비스 서버
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const packageDefinition = protoLoader.loadSync("user-service.proto", {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition);
// 데이터베이스에서 사용자 조회 (예시)
async function getUser(id) {
// 실제로는 데이터베이스에서 사용자 조회
return { id, name: "John Doe", email: "john@example.com" };
}
const server = new grpc.Server();
server.addService(proto.UserService.service, {
getUser: async (call, callback) => {
const user = await getUser(call.request.id);
callback(null, user);
},
});
server.bindAsync(
"0.0.0.0:50051",
grpc.ServerCredentials.createInsecure(),
() => {
server.start();
console.log("gRPC 서버가 port 50051에서 실행 중입니다.");
}
);
// gRPC 사용자 서비스 클라이언트
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const packageDefinition = protoLoader.loadSync("user-service.proto", {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition);
const client = new proto.UserService(
"localhost:50051",
grpc.credentials.createInsecure()
);
function getUser(id) {
return new Promise((resolve, reject) => {
client.getUser({ id }, (error, user) => {
if (error) reject(error);
resolve(user);
});
});
}
// 사용 예시
async function fetchUser() {
try {
const user = await getUser("123");
console.log("사용자:", user);
} catch (error) {
console.error("오류:", error);
}
}
fetchUser();
메시지 큐 통신 예시 (RabbitMQ)
// 메시지 발행 (publisher.js)
const amqp = require("amqplib");
async function publishMessage() {
try {
const connection = await amqp.connect("amqp://localhost");
const channel = await connection.createChannel();
const exchange = "order_events";
const routingKey = "order.created";
const message = JSON.stringify({
orderId: "12345",
userId: "123",
status: "created",
});
await channel.assertExchange(exchange, "topic", { durable: true });
channel.publish(exchange, routingKey, Buffer.from(message));
console.log(`메시지 발행됨: ${message}`);
setTimeout(() => {
connection.close();
}, 500);
} catch (error) {
console.error("오류:", error);
}
}
publishMessage();
// 메시지 구독 (subscriber.js)
const amqp = require("amqplib");
async function subscribeToMessages() {
try {
const connection = await amqp.connect("amqp://localhost");
const channel = await connection.createChannel();
const exchange = "order_events";
const queue = "notification_service_queue";
const routingKey = "order.#"; // order로 시작하는 모든 이벤트 수신
await channel.assertExchange(exchange, "topic", { durable: true });
const q = await channel.assertQueue(queue, { durable: true });
await channel.bindQueue(q.queue, exchange, routingKey);
console.log(`${queue} 대기 중...`);
channel.consume(q.queue, (msg) => {
if (msg !== null) {
const content = JSON.parse(msg.content.toString());
console.log(`메시지 수신됨: ${JSON.stringify(content)}`);
// 이메일 또는 푸시 알림 전송 로직
console.log(`주문 ${content.orderId}에 대한 알림 전송 중...`);
channel.ack(msg);
}
});
} catch (error) {
console.error("오류:", error);
}
}
subscribeToMessages();
5. 분산 로깅 및 모니터링
마이크로서비스 환경에서는 다양한 서비스의 로그를 중앙 집중화하고 모니터링하는 것이 중요합니다.
// Winston을 사용한 로깅 설정
const winston = require("winston");
const { ElasticsearchTransport } = require("winston-elasticsearch");
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: { service: "user-service" },
transports: [
new winston.transports.Console(),
new ElasticsearchTransport({
level: "info",
index: "logs",
clientOpts: { node: "http://localhost:9200" },
}),
],
});
// 로그 사용 예시
app.get("/users/:id", async (req, res) => {
logger.info("사용자 조회 요청", { userId: req.params.id });
try {
const user = await User.findById(req.params.id);
if (!user) {
logger.warn("사용자를 찾을 수 없음", { userId: req.params.id });
return res.status(404).json({ message: "사용자를 찾을 수 없습니다." });
}
logger.info("사용자 조회 성공", { userId: user.id });
res.json(user);
} catch (error) {
logger.error("사용자 조회 실패", {
userId: req.params.id,
error: error.message,
stack: error.stack,
});
res.status(500).json({ message: error.message });
}
});
Node.js 마이크로서비스의 장점
독립적인 개발과 배포: 각 서비스를 독립적으로 개발, 테스트, 배포할 수 있어 개발 속도가 빨라집니다.
기술 다양성: 서비스별로 다른 기술 스택(데이터베이스, 프레임워크 등)을 선택할 수 있습니다.
확장성: 특정 서비스만 선택적으로 확장할 수 있어 리소스를 효율적으로 사용할 수 있습니다.
장애 격리: 한 서비스의 장애가 전체 시스템에 영향을 미치지 않습니다.
팀 자율성: 각 서비스를 담당하는 팀이 독립적으로 의사 결정을 할 수 있습니다.
Node.js의 비동기 특성: Node.js의 비동기 I/O 모델은 마이크로서비스 간 통신에 적합합니다.
Node.js 마이크로서비스의 단점
복잡성 증가: 분산 시스템 관리, 서비스 간 통신, 데이터 일관성 유지 등이 복잡해집니다.
네트워크 오버헤드: 서비스 간 통신으로 인한 네트워크 지연과 오버헤드가 발생합니다.
분산 트랜잭션 어려움: 여러 서비스에 걸친 일관된 트랜잭션 관리가 어렵습니다.
디버깅과 테스트 복잡성: 여러 서비스에 걸친 문제를 디버깅하고 테스트하기가 어려워집니다.
운영 복잡성: 배포, 모니터링, 로깅 등의 운영 작업이 더 복잡해집니다.
인증 및 권한 관리 복잡성: 여러 서비스 간에 일관된 인증 및 권한 부여를 구현하기 어렵습니다.
실제 구현 사례 및 모범 사례
서비스 간 데이터 일관성 유지: Saga 패턴
여러 서비스에 걸친 트랜잭션을 관리하기 위한 패턴입니다.
// 주문 생성 Saga 예시 (choreography 기반)
// order-service.js
async function createOrder(orderData) {
try {
// 1. 주문 생성
const order = new Order({
...orderData,
status: "pending",
});
await order.save();
// 2. 이벤트 발행 (결제 서비스에서 처리)
await publishEvent("order.created", {
orderId: order._id,
userId: order.userId,
amount: order.totalAmount,
});
return order;
} catch (error) {
logger.error("주문 생성 실패", { error });
throw error;
}
}
// 주문 취소 처리 (결제 실패 시)
app.post("/orders/:id/cancel", async (req, res) => {
try {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ message: "주문을 찾을 수 없습니다." });
}
order.status = "cancelled";
await order.save();
// 캔슬 이벤트 발행 (재고 서비스에서 재고 복구)
await publishEvent("order.cancelled", { orderId: order._id });
res.json({ message: "주문이 취소되었습니다.", order });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// 결제 서비스의 이벤트 구독자
subscribeToEvent("payment.failed", async (data) => {
try {
// 결제 실패 시 주문 취소 API 호출
await axios.post(`http://localhost:3002/orders/${data.orderId}/cancel`);
logger.info("결제 실패로 인한 주문 취소", { orderId: data.orderId });
} catch (error) {
logger.error("주문 취소 실패", { error });
}
});
서킷 브레이커 패턴
서비스 간 통신에서 장애를 격리하기 위한 패턴입니다.
// Opossum 라이브러리를 사용한 서킷 브레이커 구현
const CircuitBreaker = require("opossum");
const getUserServiceOptions = {
timeout: 3000, // 3초 타임아웃
errorThresholdPercentage: 50, // 50% 이상 실패 시 서킷 오픈
resetTimeout: 10000, // 10초 후 half-open 상태로 전환
};
const getUserById = async (id) => {
try {
const response = await axios.get(`http://user-service/users/${id}`);
return response.data;
} catch (error) {
throw error;
}
};
const getUserBreaker = new CircuitBreaker(getUserById, getUserServiceOptions);
getUserBreaker.on("open", () => {
logger.warn("사용자 서비스 서킷 브레이커가 열렸습니다.");
});
getUserBreaker.on("halfOpen", () => {
logger.info("사용자 서비스 서킷 브레이커가 반열림 상태입니다.");
});
getUserBreaker.on("close", () => {
logger.info("사용자 서비스 서킷 브레이커가 닫혔습니다.");
});
// 서킷 브레이커 사용
app.get("/orders/:id", async (req, res) => {
try {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ message: "주문을 찾을 수 없습니다." });
}
let user;
try {
// 서킷 브레이커를 통한 사용자 서비스 호출
user = await getUserBreaker.fire(order.userId);
} catch (error) {
// 사용자 서비스 장애 시 기본 정보 사용
logger.warn("사용자 정보 조회 실패, 기본 정보 사용", {
userId: order.userId,
});
user = { id: order.userId, name: "Unknown User" };
}
res.json({
order,
user,
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
API 게이트웨이 고급 기능 구현
인증, 속도 제한, 로깅 등 공통 기능을 게이트웨이에서 처리합니다.
// 고급 API 게이트웨이 구현
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const rateLimit = require("express-rate-limit");
const jwt = require("jsonwebtoken");
const cors = require("cors");
const app = express();
// 공통 미들웨어
app.use(cors());
app.use(express.json());
// 요청 속도 제한
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // 15분 동안 IP당 최대 100개 요청
message: "너무 많은 요청이 발생했습니다. 나중에 다시 시도해주세요.",
});
app.use(limiter);
// JWT 인증 미들웨어
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ message: "인증 토큰이 필요합니다." });
}
const token = authHeader.split(" ")[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: "토큰이 유효하지 않습니다." });
}
req.user = user;
next();
});
};
// 로깅 미들웨어
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`
);
});
next();
});
// 서비스 경로 설정
// 인증이 필요없는 공개 경로
app.use(
"/api/auth",
createProxyMiddleware({
target: "http://auth-service:3001",
changeOrigin: true,
pathRewrite: { "^/api/auth": "" },
})
);
// 인증이 필요한 경로
app.use(
"/api/users",
authenticateJWT,
createProxyMiddleware({
target: "http://user-service:3002",
changeOrigin: true,
pathRewrite: { "^/api/users": "" },
})
);
app.use(
"/api/orders",
authenticateJWT,
createProxyMiddleware({
target: "http://order-service:3003",
changeOrigin: true,
pathRewrite: { "^/api/orders": "" },
})
);
// 오류 처리 미들웨어
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ message: "서버 오류가 발생했습니다." });
});
app.listen(3000, () => {
console.log("API 게이트웨이가 3000 포트에서 실행 중입니다.");
});
결론
Node.js는 그 자체로 마이크로서비스 아키텍처에 매우 적합한 기술입니다. 가벼운 자원 사용량, 비동기 I/O 모델, 그리고 JSON 기반 통신의 자연스러운 지원 등이 마이크로서비스 개발에 큰 장점을 제공합니다. 그러나 분산 시스템 고유의 복잡성과 도전 과제들을 잘 이해하고, 적절한 패턴과 도구를 활용하여 이를 해결해야 합니다.
마이크로서비스로의 전환은 점진적으로 이루어져야 하며, 모놀리식 애플리케이션에서 시작하여 필요에 따라 특정 기능을 분리해 나가는 것이 좋습니다. 또한, 서비스 디스커버리, 로깅, 모니터링, 트랜잭션 관리 등의 인프라를 미리 갖추는 것이 중요합니다.