[React, Node.js] 버튼 중복 클릭 / 제출 방지하기

Jenna·2023년 4월 11일
0
post-thumbnail

개발자들은 프로그램의 구조를 알기 때문에 버그가 날만한 행동은 무의식적으로 피하게 된다. 그러다보니 유저가 사용하는 경우에서 많은 버그에 맞닥뜨리게 되는데,,,

오늘의 버그는 버튼 중복 클릭으로 인한 중복 제출 버그입니다🐛


수정 전 상황

해당 버튼을 클릭하게 되면 서버로 신호가 가고 다시 신호가 넘어오면 페이지가 넘어가는 식으로 개발한 케이스. 하지만 페이지가 넘어가기 전 짧은 순간에 버튼을 여러 번 누르는 경우가 있었다. 이에 따라 소켓 호출은 누른만큼 서버로 넘어가게 됨,, 😥 눈물만 줄줄

하지만 울어서 해결되는 것은 없으니 예외처리를 구현해보자.


방법1. Disable the submit button after the first click

해당 버튼을 제출을 한번 누르고 나면 disable 시키는 방법이다. 적절한 상황에서 사용하면 가장 간편하고 좋은 방법이지만 여러 케이스에서 단순히 버튼 클릭만 막는다고 해결되진 않는다.. 어쨌든

react hook을 사용하는 방법

1. 버튼의 현재 상태를 저장하는 useState()를 선언한다.

const [isSubmitting, setIsSubmitting] = useState(false);

2. 버튼의 disabled 속성을 위에서 선언한 상태로 설정해준다

<button type="submit" disabled={isSubmitting}>
  Submit
</button>

3. submit handler에서 버튼을 누르면 isSubmitting의 값을 true로 바꾸게 구현해준다.

const handleSubmit = async (event) => {
  event.preventDefault();
  setIsSubmitting(true);

  // Your form submission logic here

  // After form submission is complete, set `isSubmitting` back to `false`:
  setIsSubmitting(false);
};

submit의 로직이 완료되면 다시 false로 설정되어서 버튼을 클릭 할 수 있게 되는 방법.


🌿 handelSubmit 안에서 state 값에 따라서 return 하는 법

handleSubmit 안에서 isSubmitting state 값이 true라면 바로 return을 해서 함수가 동작하지 않게 하는 방법도 있다.

const handleSubmit = async (event) => {
  event.preventDefault();

  if (isSubmitting) {
    return;
  }

  setIsSubmitting(true);

  // Your form submission logic here

  // After form submission is complete, set `isSubmitting` back to `false`:
  setIsSubmitting(false);
};

이런식으로 코드를 짜면 한번 버튼을 누르고 해당 submit이 완료될 때 까지 다시 누르게 된다면 handleSubmit()이 실행되지 않고 바로 return됨


방법2. token을 생성해서 사용자를 판단하고 서버에서 제출이 완료되었다면 error를 return하는 방식

유저가 2명 이상이 같은 버튼을 눌러서 중복 제출이 발생 할 때 사용할 수 있는 방식이다.


🖥️ HTTP API 방식

1. Server (Node.js)

const express = require('express');
const { v4: uuidv4 } = require('uuid');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware to parse JSON request bodies
app.use(express.json());

// In-memory store to keep track of tokens and form data
const formDataStore = {};

// Route to get a new form token
app.get('/api/form-token', (req, res) => {
  const newToken = uuidv4();
  formDataStore[newToken] = null; // Initialize the form data as null
  res.json({ token: newToken });
});

// Route to submit the form
app.post('/api/submit-form', (req, res) => {
  const { token, formData } = req.body;

  if (!formDataStore.hasOwnProperty(token)) {
    return res.status(400).json({ error: 'Invalid token' });
  }

  if (formDataStore[token] !== null) {
    return res.status(409).json({ error: 'Form already submitted' });
  }

  formDataStore[token] = formData; // Save the form data

  res.json({ message: 'Form submitted successfully' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2가지 API 루트를 만들어서 사용한다.

  • /api/form-token: form을 위한 새로운 token을 반환
  • /api/submit-form: data와 token을 받고 토큰이 유효하고 이전에 사용된 적이 없는 경우에 제출을 처리

formDataStore에서 이미 해당 값이 존재한다면 이미 제출되었다는 에러를 발생시킨다.

2. Client (react-app)

import { useState, useEffect } from 'react';

const MyForm = () => {
  const [formToken, setFormToken] = useState(null);
  const [formData, setFormData] = useState({}); // Your form data here

  // Fetch a new token when the component is mounted
  useEffect(() => {
    const fetchFormToken = async () => {
      const response = await fetch('/api/form-token');
      const data = await response.json();
      setFormToken(data.token);
    };

    fetchFormToken();
  }, []);

  const handleSubmit = async (event) => {
    event.preventDefault();

    const response = await fetch('/api/submit-form', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ token: formToken, formData }),
    });

    const data = await response.json();

    if (response.status === 409) {
      // Handle form submission conflict (already submitted)
      console.error('Form already submitted by someone else');
    } else {
      // Handle form submission success
      console.log('Form submitted successfully');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Your form fields here */}
      <button type="submit">Submit</button>
    </form>
  );
};

export default MyForm;

해당 제출 페이지가 랜더링 될 때 /api/form-tokenfetch해서 새로운 토큰을 받고 제출을 할 때 해당 토큰과 데이터를 같이 넘겨주는 방식으로 구현


🖥️ WebSocket 방식

1. Server (Node.js)

const express = require('express');
const { Server } = require('socket.io');
const { v4: uuidv4 } = require('uuid');
const http = require('http');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

const PORT = process.env.PORT || 3000;

// In-memory store to keep track of tokens and form data
const formDataStore = {};

io.on('connection', (socket) => {
  console.log('A user connected');

  // Send a new unique token to the client
  const newToken = uuidv4();
  formDataStore[newToken] = null; // Initialize the form data as null
  socket.emit('form-token', newToken);

  // Handle form submission
  socket.on('submit-form', ({ token, formData }) => {
    if (!formDataStore.hasOwnProperty(token)) {
      return socket.emit('submit-result', { success: false, error: 'Invalid token' });
    }

    if (formDataStore[token] !== null) {
      return socket.emit('submit-result', { success: false, error: 'Form already submitted' });
    }

    formDataStore[token] = formData; // Save the form data

    socket.emit('submit-result', { success: true, message: 'Form submitted successfully' });
  });

  socket.on('disconnect', () => {
    console.log('A user disconnected');
  });
});

server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2. Client (react-app)

import { useState, useEffect } from 'react';
import { io } from 'socket.io-client';

const MyForm = () => {
  const [socket, setSocket] = useState(null);
  const [formToken, setFormToken] = useState(null);
  const [formData, setFormData] = useState({}); // Your form data here

  // Connect to the WebSocket server when the component is mounted
  useEffect(() => {
    const socket = io('http://localhost:3000');
    setSocket(socket);

    socket.on('form-token', (token) => {
      setFormToken(token);
    });

    socket.on('submit-result', ({ success, message, error }) => {
      if (success) {
        console.log(message);
      } else {
        console.error(error);
      }
    });

    return () => {
      socket.disconnect();
    };
  }, []);

  const handleSubmit = (event) => {
    event.preventDefault();

    if (socket && formToken) {
      socket.emit('submit-form', { token: formToken, formData });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Your form fields here */}
      <button type="submit">Submit</button>
    </form>
  );
};

export default MyForm;

위의 HTTP API 방식과 같은 방식이다. 통신 구현 방법이 다를 뿐이지 같은 내용을 다루고 있음.


💡 UUID란?

그렇다면 위의 코드에서 token을 발급하는데 사용되는 UUID는 무엇일까?

UUID는 Universally Unique Identifier의 약자. 분산 시스템 또는 데이터베이스에서 개체 또는 레코드를 고유하게 식별하는 데 사용되는 128비트 숫자로, 고유하게 설계되어 충돌(동일한 식별자를 가진 두 개체)을 피해야 하는 응용 프로그램에서 키 또는 식별자로 사용하기에 적합하다.

UUID는 다음과 같은 형식으로 지정됨

xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

여기서 x는 16진수(0-9 또는 A-F)를 나타내고 M은 UUID 버전(1-5)을 나타내며 N은 UUID 변형을 나타내는 값이다.

UUID는 총 5가지 버전이 있다.

  1. v1: 시간 기반(타임스탬프 및 MAC 주소 사용)
  2. v2: DCE 보안(버전 1과 유사하지만 추가 POSIX UID/GID 데이터 포함)
  3. v3: MD5 해싱을 사용한 이름 기반
  4. v4: 임의(암호화 보안 난수 사용)
  5. v5: SHA-1 해싱을 사용한 이름 기반

일반적으로 v4가 랜덤값 기반이라 자주 사용된다.


node에서는 uuid관련 라이브러리를 제공한다.

npm install uuid

를 사용해서 설치할 수 있음

v4 uuid를 사용하고자 한다면 다음과 같은 코드를 상단에 추가하면 된다.

const { v4: uuidv4 } = require('uuid');
const uniqueId = uuidv4();

UUID는 고유한 토큰 역할을 하므로 양식이 이미 제출되었는지 여부를 UUID를 통해 판단하면 쉽게 확인할 수 있다!

profile
FE/Game Dev.

0개의 댓글