웹 개발
관리자 기능 변경 시 멱등성 보장하기
같은 요청을 여러 번 보내도 결과가 같도록 만드는 설계 패턴과 테스트 방법.
멱등성(idempotency)은 중요하지만 눈에 띄지 않는 개념이다. 특히 관리자 기능이나 결제 API에서는 필수다. 만약 "관리자가 삭제를 눌렀는데 데이터가 여러 번 삭제됐다"는 보고가 들어오면, 멱등성을 놓친 것이다.
멱등성이란?
같은 요청을 여러 번 보내도 결과가 같은 상태다.
POST /api/admin/users/123/delete
이 요청을 한 번 보낸 것과 5번 보낸 것의 결과가 같아야 한다. (데이터는 삭제되거나 이미 삭제된 상태)
사용자가 보는 신호
버튼을 여러 번 클릭했을 때 앱이 어떻게 반응하는가?
- 네트워크 재시도: 첫 요청이 실패하고 앱이 자동으로 재시도하면, 서버에는 같은 요청이 여러 번 도착한다.
- 사용자 실수: 버튼이 로딩 중일 때 사용자가 다시 누르면 중복 요청이 된다.
- 브라우저 새로고침: 폼 제출 후 새로고침하면 재전송된다.
개발자가 보는 신호
서버 로그에 같은 요청이 여러 번 기록되는가?
# 로그에서 요청 ID로 그룹화
grep "admin:delete_user" app.log | tail -20
만약 같은 요청이 여러 번 보이지만, DB에서는 한 번만 처리됐다면 멱등성이 보장된 것이다.
멱등성을 보장하는 방법
1. Idempotency Key 사용
클라이언트가 고유한 키를 보내고, 서버는 그 키로 중복을 판단한다.
// 클라이언트
const response = await fetch('/api/admin/delete', {
method: 'POST',
headers: {
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ userId: 123 }),
});
// 서버
app.post('/api/admin/delete', async (req, res) => {
const key = req.headers['idempotency-key'];
// 이전에 같은 key로 요청이 왔는가?
const cached = await cache.get(`idempotency:${key}`);
if (cached) return res.json(cached); // 캐시된 응답 반환
// 실제 작업 수행
const result = await deleteUser(req.body.userId);
// 결과를 캐시
await cache.set(`idempotency:${key}`, result, { ttl: 3600 });
return res.json(result);
});
2. 데이터베이스 제약
DB 레벨에서 중복을 방지한다.
-- 작업 기록 테이블
CREATE TABLE admin_actions (
id UUID PRIMARY KEY,
action_type VARCHAR(50),
target_id INT,
created_at TIMESTAMP,
UNIQUE(action_type, target_id) -- 같은 액션은 한 번만
);
같은 (action_type, target_id) 조합으로 INSERT하면 에러가 난다. 서버는 이 에러를 잡고 이미 처리된 요청으로 간주한다.
3. 상태 확인
더블 클릭해도 결과가 같도록 설계한다.
// 삭제는 멱등성이 쉽다 (이미 없는 데이터를 다시 삭제해도 없다)
async function deleteUser(userId) {
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) {
return { success: true, message: 'User not found (or already deleted)' };
}
await db.users.delete({ where: { id: userId } });
return { success: true, message: 'User deleted' };
}
// 하지만 포인트 적립은 멱등성을 보장하기 어렵다
async function addPoints(userId, points) {
// ❌ 잘못된 예: 여러 번 실행되면 포인트도 여러 번 적립됨
// await db.users.update({
// where: { id: userId },
// data: { points: { increment: points } },
// });
// ✅ 올바른 예: 이미 적립됐으면 건너뛴다
const existingAction = await db.adminActions.findFirst({
where: { action: 'add_points', userId, targetAmount: points },
});
if (existingAction) return { success: true, alreadyProcessed: true };
await db.users.update({
where: { id: userId },
data: { points: { increment: points } },
});
await db.adminActions.create({
data: { action: 'add_points', userId, targetAmount: points },
});
return { success: true };
}
테스트하기
# 같은 요청을 여러 번 보내기
for i in {1..5}; do
curl -X POST http://localhost:3000/api/admin/delete \
-H 'Idempotency-Key: test-key-123' \
-d '{"userId": 456}'
done
모든 응답이 같은가? DB에서는 데이터가 한 번만 삭제됐는가?
환경 차이와 문제
로컬 환경에서는 멱등성이 잘 작동해도, 배포 환경에서는 실패할 수 있다.
- 캐시 미스: 멱등성 키를 캐시(Redis)에 저장하는데, 배포 후 캐시가 비워지면 다시 실행된다.
- 네트워크: 응답이 오기 전에 클라이언트가 재시도하면 서버는 두 개의 요청을 본다.
- 트랜잭션: DB 트랜잭션이 겹치면 두 번 처리될 수 있다.
확인 체크리스트
- 사용자가 보는 신호: 버튼 중복 클릭이나 네트워크 재시도 시뮬레이션
- 서버 로그: 같은 요청이 여러 번 기록되는지 확인
- DB 상태: 데이터가 한 번만 변경됐는지 확인
- 멱등성 구현: Idempotency Key, DB 제약, 상태 확인 중 하나 이상 사용
- 배포 환경: 캐시와 네트워크 환경에서도 테스트
멱등성은 보이지 않지만, 없으면 아주 시끄러운 버그가 된다. 관리자 기능을 만들 때는 처음부터 고려하자.