debounce로 input 컨트롤 하기

2025년 06월 14일

디바운스(Debounce)

연속으로 호출되는 함수들 중에서 마지막 함수만 실행 되도록하는 기법.
짧은 시간 동안 여러 번 호출되는 함수가 있을 떄, 마지막 호출로부터 일정 시간이 지난 후에만 함수를 실행.

import { useEffect, useState } from 'react';
 
export function useDebounce(value: any, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);
 
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
 
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
 
  return debouncedValue;
}

Input Validation 예시

1. 이메일 유효성 검사 컴포넌트

import { useState, useEffect } from 'react';
import { useDebounce } from './useDebounce';
 
interface ValidationState {
  isValid: boolean;
  errorMessage: string;
  isChecking: boolean;
}
 
export function EmailInput() {
  const [email, setEmail] = useState('');
  const [validation, setValidation] = useState<ValidationState>({
    isValid: true,
    errorMessage: '',
    isChecking: false
  });
 
  // 500ms 후에 validation 체크
  const debouncedEmail = useDebounce(email, 500);
 
  useEffect(() => {
    if (!debouncedEmail) {
      setValidation({
        isValid: true,
        errorMessage: '',
        isChecking: false
      });
      return;
    }
 
    // 유효성 검사 시작
    setValidation(prev => ({ ...prev, isChecking: true }));
 
    // 이메일 형식 검증
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const isValidFormat = emailRegex.test(debouncedEmail);
 
    // 비동기 중복 검사 시뮬레이션
    setTimeout(() => {
      if (!isValidFormat) {
        setValidation({
          isValid: false,
          errorMessage: '올바른 이메일 형식이 아닙니다.',
          isChecking: false
        });
      } else if (debouncedEmail === 'test@test.com') {
        setValidation({
          isValid: false,
          errorMessage: '이미 사용 중인 이메일입니다.',
          isChecking: false
        });
      } else {
        setValidation({
          isValid: true,
          errorMessage: '',
          isChecking: false
        });
      }
    }, 300);
  }, [debouncedEmail]);
 
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
    // 타이핑 중에는 검사 상태로 설정
    if (e.target.value && !validation.isChecking) {
      setValidation(prev => ({ ...prev, isChecking: true }));
    }
  };
 
  return (
    <div className="input-container">
      <label htmlFor="email">이메일</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={handleChange}
        placeholder="이메일을 입력해주세요"
        className={`input ${!validation.isValid ? 'error' : ''} ${validation.isValid && email ? 'success' : ''}`}
      />
 
      {validation.isChecking && (
        <span className="checking">✓ 확인 중...</span>
      )}
 
      {validation.errorMessage && (
        <span className="error-message">❌ {validation.errorMessage}</span>
      )}
 
      {validation.isValid && email && !validation.isChecking && (
        <span className="success-message">✅ 사용 가능한 이메일입니다.</span>
      )}
    </div>
  );
}

2. 비밀번호 강도 검사 컴포넌트

import { useState, useEffect } from 'react';
import { useDebounce } from './useDebounce';
 
interface PasswordStrength {
  score: number;
  message: string;
  color: string;
}
 
export function PasswordInput() {
  const [password, setPassword] = useState('');
  const [strength, setStrength] = useState<PasswordStrength>({
    score: 0,
    message: '',
    color: 'gray'
  });
 
  // 300ms 후에 비밀번호 강도 체크
  const debouncedPassword = useDebounce(password, 300);
 
  useEffect(() => {
    if (!debouncedPassword) {
      setStrength({ score: 0, message: '', color: 'gray' });
      return;
    }
 
    const checkPasswordStrength = (pwd: string): PasswordStrength => {
      let score = 0;
      let message = '';
      let color = 'red';
 
      // 길이 체크
      if (pwd.length >= 8) score += 1;
      if (pwd.length >= 12) score += 1;
 
      // 대소문자 체크
      if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score += 1;
 
      // 숫자 체크
      if (/\d/.test(pwd)) score += 1;
 
      // 특수문자 체크
      if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) score += 1;
 
      switch (score) {
        case 0:
        case 1:
          message = '매우 약함';
          color = 'red';
          break;
        case 2:
          message = '약함';
          color = 'orange';
          break;
        case 3:
          message = '보통';
          color = 'yellow';
          break;
        case 4:
          message = '강함';
          color = 'lightgreen';
          break;
        case 5:
          message = '매우 강함';
          color = 'green';
          break;
      }
 
      return { score, message, color };
    };
 
    setStrength(checkPasswordStrength(debouncedPassword));
  }, [debouncedPassword]);
 
  return (
    <div className="input-container">
      <label htmlFor="password">비밀번호</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호를 입력해주세요"
        className="input"
      />
 
      {password && (
        <div className="password-strength">
          <div className="strength-bar">
            {[1, 2, 3, 4, 5].map((level) => (
              <div
                key={level}
                className={`strength-segment ${strength.score >= level ? 'active' : ''}`}
                style={{
                  backgroundColor: strength.score >= level ? strength.color : '#e0e0e0'
                }}
              />
            ))}
          </div>
          <span className="strength-text" style={{ color: strength.color }}>
            {strength.message}
          </span>
        </div>
      )}
 
      {password && (
        <div className="password-requirements">
          <p>비밀번호 요구사항:</p>
          <ul>
            <li className={password.length >= 8 ? 'valid' : ''}>
              8자 이상 {password.length >= 8 && '✓'}
            </li>
            <li className={/[a-z]/.test(password) && /[A-Z]/.test(password) ? 'valid' : ''}>
              대소문자 포함 {/[a-z]/.test(password) && /[A-Z]/.test(password) && '✓'}
            </li>
            <li className={/\d/.test(password) ? 'valid' : ''}>
              숫자 포함 {/\d/.test(password) && '✓'}
            </li>
            <li className={/[!@#$%^&*(),.?":{}|<>]/.test(password) ? 'valid' : ''}>
              특수문자 포함 {/[!@#$%^&*(),.?":{}|<>]/.test(password) && '✓'}
            </li>
          </ul>
        </div>
      )}
    </div>
  );
}

3. 사용자명 중복 검사 컴포넌트

import { useState, useEffect } from 'react';
import { useDebounce } from './useDebounce';
 
export function UsernameInput() {
  const [username, setUsername] = useState('');
  const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'invalid'>('idle');
  const [message, setMessage] = useState('');
 
  // 800ms 후에 중복 검사 실행
  const debouncedUsername = useDebounce(username, 800);
 
  useEffect(() => {
    if (!debouncedUsername) {
      setStatus('idle');
      setMessage('');
      return;
    }
 
    // 사용자명 형식 검증
    const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
    if (!usernameRegex.test(debouncedUsername)) {
      setStatus('invalid');
      setMessage('3-20자의 영문, 숫자, 언더스코어만 사용 가능합니다.');
      return;
    }
 
    // 중복 검사 시작
    setStatus('checking');
    setMessage('확인 중...');
 
    // API 호출 시뮬레이션
    const checkUsername = async (name: string) => {
      // 금지된 사용자명 목록
      const forbiddenNames = ['admin', 'root', 'user', 'test', 'guest'];
 
      return new Promise<boolean>((resolve) => {
        setTimeout(() => {
          resolve(!forbiddenNames.includes(name.toLowerCase()));
        }, 500);
      });
    };
 
    checkUsername(debouncedUsername)
      .then((isAvailable) => {
        if (isAvailable) {
          setStatus('available');
          setMessage('사용 가능한 사용자명입니다.');
        } else {
          setStatus('taken');
          setMessage('이미 사용 중인 사용자명입니다.');
        }
      })
      .catch(() => {
        setStatus('invalid');
        setMessage('확인 중 오류가 발생했습니다.');
      });
  }, [debouncedUsername]);
 
  const getInputClassName = () => {
    switch (status) {
      case 'available':
        return 'input success';
      case 'taken':
      case 'invalid':
        return 'input error';
      case 'checking':
        return 'input checking';
      default:
        return 'input';
    }
  };
 
  const getStatusIcon = () => {
    switch (status) {
      case 'checking':
        return '🔄';
      case 'available':
        return '✅';
      case 'taken':
      case 'invalid':
        return '❌';
      default:
        return '';
    }
  };
 
  return (
    <div className="input-container">
      <label htmlFor="username">사용자명</label>
      <div className="input-wrapper">
        <input
          id="username"
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="사용자명을 입력해주세요"
          className={getInputClassName()}
        />
        <span className="status-icon">{getStatusIcon()}</span>
      </div>
 
      {message && (
        <span className={`message ${status === 'available' ? 'success' : status === 'checking' ? 'info' : 'error'}`}>
          {message}
        </span>
      )}
    </div>
  );
}

4. 통합 폼 예시

import { useState } from 'react';
import { EmailInput } from './EmailInput';
import { PasswordInput } from './PasswordInput';
import { UsernameInput } from './UsernameInput';
 
export function RegistrationForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
 
    // 폼 제출 로직
    try {
      // API 호출
      await new Promise(resolve => setTimeout(resolve, 2000));
      alert('회원가입이 완료되었습니다!');
    } catch (error) {
      alert('회원가입 중 오류가 발생했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  };
 
  return (
    <form onSubmit={handleSubmit} className="registration-form">
      <h2>회원가입</h2>
 
      <UsernameInput />
      <EmailInput />
      <PasswordInput />
 
      <button
        type="submit"
        disabled={isSubmitting}
        className="submit-button"
      >
        {isSubmitting ? '가입 중...' : '회원가입'}
      </button>
    </form>
  );
}

이렇게 UI적으로 일부러 감지를 늦춰 실시간 피드백을 제공하고 불필요한 검증을 방지