일반적으로 멱등성은 의도하지 않은 부작용 없이 작업을 안전하게 재시도할 수 있도록 도와주는 소프트웨어 개발의 중요한 개념이다. 여러 서버에서 요청을 처리할 수 있고 네트워크 장애로 인해 중복 요청이 발생할 수 있는 분산 시스템에서 특히 중요하다.
멱등성의 일반적인 사용 사례 중 하나는 결제나 예금과 같은 금융 거래를 처리할 때이다. 이러한 시나리오에서는 네트워크 장애나 기타 문제로 인해 클라이언트가 여러 요청을 보내더라도 트랜잭션이 한 번만 처리되도록 해야 한다.
멱등성(Idempotency)은 소프트웨어 개발에서 필수적인 개념이며, 특히 트랜잭션 처리가 여러 참여자간에 일관성을 유지해야 하는 상황에서 더욱 중요하다. 이 글에서는 멱등성 개념과 서버-클라이언트 통신, TRX ID 생성, 데이터베이스 트랜잭션에서의 구현을 자바스크립트 예제 코드를 통해 살펴보고자 한다.
서버-클라이언트 통신의 멱등성
네트워크를 통한 통신의 주된 임무는 양쪽에서 동일한 상태를 유지하는 것이다. 이는 특히 요청이 삭제되거나 중복될 수 있는 시나리오에서 어려울 수 있다. 이를 극복하기 위해 멱등성을 사용하여 결과를 변경하지 않고 동일한 요청을 여러 번 적용할 수 있도록 할 수 있다.
서버-클라이언트 통신에서 멱등성을 구현하는 한 가지 방법은 멱등성 연산을 사용하는 것이다. 이는 결과를 변경하지 않고 여러 번 적용할 수 있는 연산이다. 예를 들어, 자바스크립트에서는 다음과 같이 서버에서 데이터를 검색하는 멱등성 함수를 만들 수 있다.
async function fetchData() {
const response = await fetch('https://example.com/data');
const data = await response.json();
return data;
}
const idempotentFetchData = (() => {
let data = null;
return async () => {
if (data === null) {
data = await fetchData();
}
return data;
};
})();
idempotentFetchData 함수는 fetchData 함수를 사용하여 서버에서 데이터를 검색하지만 한 번만 검색한다. 이후 호출 시 캐시된 데이터를 반환하여 매번 동일한 결과가 반환되도록 한다.
TRX ID 생성에서 멱등성 구현하기
트랜잭션을 처리해야 하는 시나리오에서는 중복 요청으로 인해 일관되지 않은 상태가 발생하지 않도록 고유한 트랜잭션 ID(TRX ID)를 생성할 필요가 있다. 난수, UUID, 해시 함수 등 여러 가지 TRX ID 생성 방법이 있다.
TRX ID를 사용하여 멱등성을 구현하려면 각 요청에 대해 고유 ID를 생성하고 이를 사용하여 중복 요청을 식별하고 일관되게 처리할 수 있다. 자바스크립트에서는 uuid 패키지를 사용하여 UUID를 생성하고 이를 사용하여 다음과 같이 멱등성을 구현할 수 있다.
const uuid = require('uuid');
async function processPayment(paymentData, trxId = uuid.v4()) {
// Check if payment with the same TRX ID has already been processed
const existingPayment = await PaymentModel.findOne({ trxId });
if (existingPayment) {
return existingPayment;
}
// Process payment
const result = await PaymentProcessor.process(paymentData);
// Save payment record with TRX ID
const payment = new PaymentModel({
trxId,
result
});
await payment.save();
return payment;
}
processPayment 함수는 uuid.v4() 함수를 사용하여 UUID를 생성하고 이를 사용하여 동일한 TRX ID를 가진 결제가 이미 처리되었는지 여부를 식별한다. 이미 처리되었다면 기존 결제 기록을 반환한다. 그렇지 않으면 결제를 처리하고 TRX ID가 포함된 새 결제 레코드를 저장한다.
멱등성 및 데이터베이스 트랜잭션
트랜잭션과 관계형 데이터베이스를 사용하는 것이 멱등성을 구현하는 좋은 방법처럼 보일 수 있지만, 중복 키 예외가 발생할 가능성이 있어 멱등성이 깨질 수 있다. 예를 들어, 요청이 여러 번 병렬로 전송되어 동일한 TRX ID를 가진 기존 행이 있는지 확인하면 “no, this row does not exist”라는 응답이 여러 번 수신되어 일관되지 않은 상태가 될 수 있다.
따라서 공유 잠금을 구현해야 하는데, 이는 복잡하고 유지 관리가 어려울 수 있다.
// start a transaction
await sequelize.transaction(async (t) => {
try {
// check if the transaction id exists
const existingTransaction = await Transaction.findOne({
where: {
id: transactionId
},
transaction: t
});
// if the transaction exists, return the original response
if (existingTransaction) {
return existingTransaction.response;
}
// process the request and save the response
const response = await processRequest(request);
const transaction = await Transaction.create({
id: transactionId,
response: response
}, { transaction: t });
// commit the transaction
await t.commit();
// return the response
return response;
} catch (error) {
// rollback the transaction if an error occurs
await t.rollback();
throw error;
}
});
sequelize 라이브러리를 사용하여 트랜잭션을 구현한 예이다. 트랜잭션을 시작하고 동일한 ID를 가진 트랜잭션이 데이터베이스에 이미 존재하는지 확인한다. 존재하면 원래 응답을 반환한다.
트랜잭션이 존재하지 않으면 요청을 처리하고 데이터베이스에 트랜잭션 ID와 함께 응답을 저장한다. 그런 다음 트랜잭션을 커밋하고 응답을 반환한다.
트랜잭션 중에 오류가 발생하면 트랜잭션을 롤백하고 오류를 발생시킨다. 이 접근 방식은 효과적이지만 복잡하고 유지 관리가 어려울 수 있다.
Redis를 사용하는 것은 확장 가능하고 안정적인 방식으로 멱등성을 구현하는 다른 방법이 될 수 있다. Redis는 원자(atomic) 연산을 제공하므로 여러 요청이 경합 조건이나 충돌을 일으키지 않고 안전하게 Redis 데이터에 액세스할 수 있다.
const redis = require("redis");
const client = redis.createClient();
function processPayment(req, res) {
const trxId = req.headers["x-trx-id"];
const amount = req.body.amount;
// Check if the transaction ID already exists
client.get(trxId, (err, reply) => {
if (reply) {
// If the transaction ID already exists, return the original response
res.status(200).send(JSON.parse(reply));
} else {
// Otherwise, process the payment and store the response in Redis
makePayment(amount, (err, response) => {
if (err) {
res.status(500).send({ error: "Payment failed" });
} else {
client.set(trxId, JSON.stringify(response), "EX", 60);
res.status(200).send(response);
}
});
}
});
}
Redis를 사용하여 첫 번째 요청의 응답을 60초의 TTL(Time-to-Live)로 저장한다. 동일한 트랜잭션 ID를 가진 두 번째 요청이 TTL 내에 도착하면 Redis는 요청을 다시 처리하는 대신 저장된 응답을 반환한다.
Redis가 데이터 손실이나 충돌을 일으키지 않고 대량의 요청을 처리할 수 있기 때문에, 멱등성을 위해 Redis를 사용하면 확장성과 안정성이 향상된다. 하지만 인메모리 솔루션에 비해 추가적인 설정과 구성이 필요하다.
const inMemoryProtector = {};
function processTransaction(idempotencyKey, transactionFunction) {
if (inMemoryProtector[idempotencyKey]) {
// transaction already in progress
return inMemoryProtector[idempotencyKey];
}
// start transaction
const transactionPromise = new Promise((resolve, reject) => {
inMemoryProtector[idempotencyKey] = transactionFunction()
.then((result) => {
// commit transaction and clear protector
delete inMemoryProtector[idempotencyKey];
resolve(result);
})
.catch((error) => {
// rollback transaction and clear protector
delete inMemoryProtector[idempotencyKey];
reject(error);
});
});
// return promise
return transactionPromise;
}
진행 중인 트랜잭션의 상태를 저장하기 위해 inMemoryProtector 객체를 정의한 예제이다. processTransaction 함수는 idempotencyKey 매개변수와 transactionFunction 매개변수를 받는다. 이 함수는 주어진 idempotencyKey를 가진 트랜잭션이 이미 진행 중인지 확인하고, 진행 중이면 해당 트랜잭션의 프로미스를 반환한다. 그렇지 않으면 트랜잭션Function을 호출하고 해당 프로미스를 반환하여 새 트랜잭션을 시작한다.
transactionFunction은 데이터베이스에서 레코드를 업데이트하는 것과 같이 멱등 상태여야 하는 일부 작업을 수행하는 함수이다. 작업이 성공하면 트랜잭션이 커밋되고 processTransaction 함수는 작업 결과로 resolve된다. 오류가 발생하면 트랜잭션이 롤백되고 processTransaction 함수가 오류와 함께 reject된다.
마무리
멱등성은 분산 시스템과 API에서 필수적인 개념이다. 이를 통해 실패와 재시도에도 요청이 일관되고 안정적으로 처리되도록 보장할 수 있다.
멱등성의 기본 개념으로 시작해서 실제로 구현하는 다양한 방법을 정리했다. API에 멱등성을 구현하면 서비스의 안정성과 일관성을 개선하여 사용자 만족도를 높일 수 있다.