ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 클라이언트 중복 방어의 한계와 서버 이중 방어 — isSubmitting부터 idempotency key까지
    프론트엔드 2026. 6. 7. 19:05

    배경

    피드백 생성 기능을 구현하면서 한 가지 문제를 발견했다.

    사용자가 버튼을 빠르게 여러 번 클릭하면 GPT API 호출이 그 횟수만큼 전송됐다. API 비용은 호출 횟수에 비례하고 동일한 피드백이 여러 번 생성되면 DB도 오염되어 막아야 했다.

    1차 시도 — isSubmitting으로 클라이언트 방어

    가장 먼저 떠오른 건 버튼 상태 관리였다. API 호출 중에는 버튼을 비활성화하면 중복 클릭 자체를 막을 수 있다.

    const [isSubmitting, setIsSubmitting] = useState(false);
    
    const handleGenerate = async () => {
      if (isSubmitting) return;
      setIsSubmitting(true);
      try {
        await generateFeedback();
      } finally {
        setIsSubmitting(false);
      }
    };
    
    <button disabled={isSubmitting} onClick={handleGenerate}>
      피드백 생성
    </button>

    UI 레벨에서는 깔끔하게 막혔다. 하지만 구현 직후 한 가지 의문이 생겼다.

     

    한계 발견 — 네트워크 탭으로 우회 가능

    브라우저 개발자 도구 네트워크 탭에서 요청을 직접 복사해 재전송하면 isSubmitting 상태와 무관하게 서버로 요청이 전달된다. 클라이언트 방어는 어디까지나 UI 레벨의 방어다. 서버 입장에서는 어디서 온 요청인지 알 수 없다.

    즉, 클라이언트 방어만으로는 완전하지 않다.

     

    2차 방어 — 서버에서 DB 체크

    서버에서도 방어 로직을 추가했다. 피드백 생성 요청이 들어오면 DB에 해당 챌린지의 피드백이 이미 존재하는지 먼저 확인한다. 있으면 GPT를 재호출하지 않고 기존 데이터를 그대로 반환한다.

    // 서버 API route
    const existing = await db.feedback.findFirst({
      where: { challengeId },
    });
    
    if (existing) {
      return Response.json({ feedback: existing });
    }
    
    // 없을 때만 GPT 호출
    const feedback = await generateWithGPT(routineData);
    await db.feedback.create({ data: { challengeId, ...feedback } });';

     

    이렇게 하면 클라이언트를 우회해 요청이 들어와도 GPT가 중복 호출되지 않는다.

     

    개념 정리

    이중 방어란? 클라이언트와 서버 양쪽에 각각 독립적인 방어 로직을 두는 패턴이다. 클라이언트 방어가 뚫려도 서버에서 한 번 더 걸러낸다. 어느 한쪽만 믿지 않는다는 원칙이다.

    idempotency key란? 동일한 요청을 여러 번 보내도 결과가 한 번만 처리되도록 보장하는 고유 키다. 클라이언트가 요청 시 UUID 같은 고유값을 헤더에 담아 보내고 서버는 이 키를 기준으로 이미 처리된 요청인지 판단한다.

    // 클라이언트
    const idempotencyKey = crypto.randomUUID();
    
    await fetch('/api/feedback', {
      method: 'POST',
      headers: {
        'Idempotency-Key': idempotencyKey,
      },
    });
    
    // 서버
    const key = request.headers.get('Idempotency-Key');
    const processed = await db.idempotencyKeys.findFirst({ where: { key } });
    
    if (processed) {
      return Response.json({ feedback: processed.result });
    }

     

    회고

    isSubmitting으로 클라이언트 방어를 구현했을 때 처음엔 충분하다고 생각했다. 그런데 네트워크 탭을 직접 조작하면 우회가 가능하다는 걸 깨닫고 나서 방어의 범위를 다시 생각하게 됐다.

    클라이언트 방어는 사용자 경험을 위한 것이고 서버 방어는 데이터 무결성을 위한 것이다. 둘은 목적이 다르고 역할도 다르다. 어느 한쪽만으 로는 완전하지 않다.

    다음에 유사한 기능을 구현한다면 idempotency key(멱등성 키) 도입을 진지하게 고려할 것 같다. 

    '프론트엔드' 카테고리의 다른 글

    미들웨어(프록시)로 유연하게 처리 해보기  (0) 2026.06.21
    프로젝트를 다방면으로 생각해보기  (0) 2026.06.14
    AbortController 도입시도  (0) 2026.06.01
    React Activity?  (0) 2026.03.12
    쿠키, 세션, 스토리지  (1) 2025.12.06
Designed by Tistory.