← 전체 글로 돌아가기

웹 개발

관리자 기능 변경 시 멱등성 보장하기

같은 요청을 여러 번 보내도 결과가 같도록 만드는 설계 패턴과 테스트 방법.

멱등성(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 트랜잭션이 겹치면 두 번 처리될 수 있다.

확인 체크리스트

  1. 사용자가 보는 신호: 버튼 중복 클릭이나 네트워크 재시도 시뮬레이션
  2. 서버 로그: 같은 요청이 여러 번 기록되는지 확인
  3. DB 상태: 데이터가 한 번만 변경됐는지 확인
  4. 멱등성 구현: Idempotency Key, DB 제약, 상태 확인 중 하나 이상 사용
  5. 배포 환경: 캐시와 네트워크 환경에서도 테스트

멱등성은 보이지 않지만, 없으면 아주 시끄러운 버그가 된다. 관리자 기능을 만들 때는 처음부터 고려하자.