← 목록으로

챕터 13: 이벤트 처리 마스터하기

서론

웹 애플리케이션은 사용자와의 상호작용으로 살아 움직입니다. 클릭, 타이핑, 마우스 이동, 스크롤 등 사용자의 모든 행동은 이벤트를 발생시킵니다. 이러한 이벤트를 적절히 처리하는 것이 인터랙티브한 웹 애플리케이션을 만드는 핵심입니다.

React에서 이벤트를 처리하는 방법은 일반 HTML과 비슷하지만, 몇 가지 중요한 차이점이 있습니다. 이번 챕터에서는 React의 이벤트 시스템을 완벽하게 이해하고, 다양한 이벤트를 처리하는 방법을 실습을 통해 마스터해보겠습니다.

본론

React 이벤트의 특징

React에서 이벤트를 처리할 때 알아야 할 중요한 차이점들:

  1. 카멜케이스(camelCase) 사용: onclick이 아닌 onClick
  2. 함수를 전달: 문자열이 아닌 함수를 전달
  3. SyntheticEvent: React는 브라우저 간 호환성을 위해 이벤트를 감싸서 제공
'use client'

import { useState } from 'react'

export default function EventBasics() {
  const [message, setMessage] = useState('')
  
  // 이벤트 핸들러 함수
  const handleClick = () => {
    setMessage('버튼이 클릭되었습니다!')
  }
  
  // 이벤트 객체 받기
  const handleClickWithEvent = (e) => {
    console.log('이벤트 객체:', e)
    console.log('클릭한 요소:', e.target)
    setMessage(`버튼 "${e.target.textContent}"를 클릭했습니다`)
  }
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">이벤트 처리 기초</h2>
      
      {/* 기본 클릭 이벤트 */}
      <button
        onClick={handleClick}
        className="bg-blue-500 text-white px-4 py-2 rounded mr-2"
      >
        기본 클릭
      </button>
      
      {/* 이벤트 객체 활용 */}
      <button
        onClick={handleClickWithEvent}
        className="bg-green-500 text-white px-4 py-2 rounded mr-2"
      >
        이벤트 객체 활용
      </button>
      
      {/* 인라인 핸들러 */}
      <button
        onClick={() => setMessage('인라인 핸들러 실행!')}
        className="bg-purple-500 text-white px-4 py-2 rounded"
      >
        인라인 핸들러
      </button>
      
      {message && (
        <p className="mt-4 p-4 bg-gray-100 rounded">{message}</p>
      )}
    </div>
  )
}

주요 이벤트 타입들

웹 개발에서 자주 사용되는 이벤트들을 살펴보겠습니다:

'use client'

import { useState } from 'react'

export default function CommonEvents() {
  const [eventLog, setEventLog] = useState([])
  
  const addLog = (message) => {
    setEventLog(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`])
  }
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-6">다양한 이벤트 타입</h2>
      
      <div className="grid grid-cols-2 gap-6">
        <div className="space-y-4">
          {/* 마우스 이벤트 */}
          <div className="border p-4 rounded">
            <h3 className="font-bold mb-2">마우스 이벤트</h3>
            <div
              className="bg-blue-100 p-8 rounded text-center cursor-pointer"
              onClick={() => addLog('클릭됨')}
              onDoubleClick={() => addLog('더블클릭됨')}
              onMouseEnter={() => addLog('마우스 진입')}
              onMouseLeave={() => addLog('마우스 떠남')}
              onMouseMove={() => console.log('마우스 이동중')}
            >
              마우스를 여기로 가져와보세요
            </div>
          </div>
          
          {/* 키보드 이벤트 */}
          <div className="border p-4 rounded">
            <h3 className="font-bold mb-2">키보드 이벤트</h3>
            <input
              type="text"
              className="w-full border rounded px-3 py-2"
              placeholder="여기에 타이핑해보세요"
              onKeyDown={(e) => addLog(`키 눌림: ${e.key}`)}
              onKeyUp={(e) => addLog(`키 떼짐: ${e.key}`)}
              onKeyPress={(e) => console.log('키 프레스:', e.key)}
            />
          </div>
          
          {/* 포커스 이벤트 */}
          <div className="border p-4 rounded">
            <h3 className="font-bold mb-2">포커스 이벤트</h3>
            <input
              type="text"
              className="w-full border rounded px-3 py-2"
              placeholder="포커스/블러 테스트"
              onFocus={() => addLog('포커스 받음')}
              onBlur={() => addLog('포커스 잃음')}
            />
          </div>
          
          {/* 폼 이벤트 */}
          <div className="border p-4 rounded">
            <h3 className="font-bold mb-2">폼 이벤트</h3>
            <form onSubmit={(e) => {
              e.preventDefault()
              addLog('폼 제출됨')
            }}>
              <input
                type="text"
                className="w-full border rounded px-3 py-2 mb-2"
                onChange={(e) => addLog(`입력 변경: ${e.target.value}`)}
                placeholder="텍스트 입력"
              />
              <button
                type="submit"
                className="bg-blue-500 text-white px-4 py-2 rounded"
              >
                제출
              </button>
            </form>
          </div>
        </div>
        
        {/* 이벤트 로그 */}
        <div className="border p-4 rounded bg-gray-50">
          <h3 className="font-bold mb-2">이벤트 로그</h3>
          <div className="h-96 overflow-y-auto space-y-1">
            {eventLog.length === 0 ? (
              <p className="text-gray-500">이벤트가 여기에 표시됩니다</p>
            ) : (
              eventLog.map((log, index) => (
                <div key={index} className="text-sm bg-white p-2 rounded">
                  {log}
                </div>
              ))
            )}
          </div>
          <button
            onClick={() => setEventLog([])}
            className="mt-2 text-sm text-red-500 hover:text-red-700"
          >
            로그 클리어
          </button>
        </div>
      </div>
    </div>
  )
}

이벤트 핸들러에 매개변수 전달하기

때로는 이벤트 핸들러에 추가 데이터를 전달해야 할 때가 있습니다:

'use client'

import { useState } from 'react'

export default function EventParameters() {
  const [selectedItem, setSelectedItem] = useState(null)
  const [cart, setCart] = useState([])
  
  const products = [
    { id: 1, name: '노트북', price: 1500000 },
    { id: 2, name: '마우스', price: 30000 },
    { id: 3, name: '키보드', price: 100000 },
    { id: 4, name: '모니터', price: 400000 }
  ]
  
  // 매개변수를 받는 이벤트 핸들러
  const handleSelectItem = (item) => {
    setSelectedItem(item)
  }
  
  // 이벤트 객체와 매개변수를 함께 받기
  const handleAddToCart = (e, item) => {
    e.stopPropagation()  // 이벤트 버블링 방지
    setCart([...cart, item])
  }
  
  // 커링을 사용한 방법
  const handleRemoveFromCart = (itemId) => (e) => {
    setCart(cart.filter(item => item.id !== itemId))
  }
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">상품 목록</h2>
      
      <div className="grid grid-cols-2 gap-6">
        {/* 상품 목록 */}
        <div>
          <h3 className="font-bold mb-3">상품 선택</h3>
          <div className="space-y-2">
            {products.map(product => (
              <div
                key={product.id}
                onClick={() => handleSelectItem(product)}
                className={`border p-4 rounded cursor-pointer transition-all ${
                  selectedItem?.id === product.id
                    ? 'border-blue-500 bg-blue-50'
                    : 'hover:border-gray-400'
                }`}
              >
                <div className="flex justify-between items-center">
                  <div>
                    <h4 className="font-semibold">{product.name}</h4>
                    <p className="text-gray-600">
                      {product.price.toLocaleString()}원
                    </p>
                  </div>
                  <button
                    onClick={(e) => handleAddToCart(e, product)}
                    className="bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600"
                  >
                    담기
                  </button>
                </div>
              </div>
            ))}
          </div>
        </div>
        
        {/* 장바구니 */}
        <div>
          <h3 className="font-bold mb-3">장바구니 ({cart.length})</h3>
          <div className="border rounded p-4 min-h-[200px]">
            {cart.length === 0 ? (
              <p className="text-gray-500 text-center">장바구니가 비어있습니다</p>
            ) : (
              <div className="space-y-2">
                {cart.map((item, index) => (
                  <div
                    key={`${item.id}-${index}`}
                    className="flex justify-between items-center bg-gray-50 p-2 rounded"
                  >
                    <span>{item.name}</span>
                    <button
                      onClick={handleRemoveFromCart(item.id)}
                      className="text-red-500 hover:text-red-700"
                    >
                      삭제
                    </button>
                  </div>
                ))}
                <div className="border-t pt-2 mt-2">
                  <p className="font-bold">
                    총액: {cart.reduce((sum, item) => sum + item.price, 0).toLocaleString()}원
                  </p>
                </div>
              </div>
            )}
          </div>
        </div>
      </div>
      
      {/* 선택된 상품 정보 */}
      {selectedItem && (
        <div className="mt-6 p-4 bg-blue-100 rounded">
          <h3 className="font-bold">선택된 상품</h3>
          <p>{selectedItem.name} - {selectedItem.price.toLocaleString()}원</p>
        </div>
      )}
    </div>
  )
}

이벤트 버블링과 캡처링

이벤트가 DOM 트리를 통해 전파되는 방식을 이해하는 것이 중요합니다:

'use client'

import { useState } from 'react'

export default function EventPropagation() {
  const [log, setLog] = useState([])
  
  const addLog = (message) => {
    setLog(prev => [...prev, message])
  }
  
  const clearLog = () => setLog([])
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">이벤트 전파</h2>
      
      <div className="grid grid-cols-2 gap-6">
        {/* 이벤트 버블링 예제 */}
        <div>
          <h3 className="font-bold mb-3">이벤트 버블링</h3>
          <div
            onClick={() => addLog('외부 DIV 클릭 (버블링)')}
            className="bg-red-100 p-8 rounded cursor-pointer"
          >
            외부 DIV
            <div
              onClick={() => addLog('중간 DIV 클릭 (버블링)')}
              className="bg-red-200 p-6 mt-4 rounded"
            >
              중간 DIV
              <div
                onClick={() => addLog('내부 DIV 클릭 (버블링)')}
                className="bg-red-300 p-4 mt-4 rounded"
              >
                내부 DIV
              </div>
            </div>
          </div>
        </div>
        
        {/* 이벤트 전파 중단 예제 */}
        <div>
          <h3 className="font-bold mb-3">이벤트 전파 중단</h3>
          <div
            onClick={() => addLog('외부 DIV 클릭 (중단)')}
            className="bg-blue-100 p-8 rounded cursor-pointer"
          >
            외부 DIV
            <div
              onClick={(e) => {
                e.stopPropagation()
                addLog('중간 DIV 클릭 (전파 중단됨)')
              }}
              className="bg-blue-200 p-6 mt-4 rounded"
            >
              중간 DIV (stopPropagation)
              <div
                onClick={(e) => {
                  e.stopPropagation()
                  addLog('내부 DIV 클릭 (전파 중단됨)')
                }}
                className="bg-blue-300 p-4 mt-4 rounded"
              >
                내부 DIV (stopPropagation)
              </div>
            </div>
          </div>
        </div>
      </div>
      
      {/* 이벤트 로그 */}
      <div className="mt-6 border rounded p-4 bg-gray-50">
        <div className="flex justify-between items-center mb-2">
          <h3 className="font-bold">이벤트 발생 순서</h3>
          <button
            onClick={clearLog}
            className="text-sm text-red-500 hover:text-red-700"
          >
            클리어
          </button>
        </div>
        <div className="space-y-1 max-h-40 overflow-y-auto">
          {log.length === 0 ? (
            <p className="text-gray-500">DIV를 클릭해보세요</p>
          ) : (
            log.map((message, index) => (
              <div key={index} className="text-sm bg-white p-2 rounded">
                {index + 1}. {message}
              </div>
            ))
          )}
        </div>
      </div>
    </div>
  )
}

실습: 인터랙티브 게임 만들기

이벤트 처리를 활용한 간단한 반응 속도 게임을 만들어봅시다:

'use client'

import { useState, useEffect } from 'react'

export default function ReactionGame() {
  const [gameState, setGameState] = useState('ready')  // ready, waiting, click, finished
  const [startTime, setStartTime] = useState(null)
  const [reactionTime, setReactionTime] = useState(null)
  const [scores, setScores] = useState([])
  const [timeoutId, setTimeoutId] = useState(null)
  
  const startGame = () => {
    setGameState('waiting')
    setReactionTime(null)
    
    const delay = Math.random() * 3000 + 2000  // 2~5초 랜덤 대기
    const id = setTimeout(() => {
      setGameState('click')
      setStartTime(Date.now())
    }, delay)
    
    setTimeoutId(id)
  }
  
  const handleClick = () => {
    if (gameState === 'waiting') {
      // 너무 빨리 클릭함
      clearTimeout(timeoutId)
      setGameState('ready')
      setReactionTime('너무 빨리 클릭했습니다!')
    } else if (gameState === 'click') {
      // 정상 클릭
      const time = Date.now() - startTime
      setReactionTime(time)
      setScores([...scores, time])
      setGameState('finished')
    }
  }
  
  const getAverageScore = () => {
    if (scores.length === 0) return 0
    return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
  }
  
  const getBestScore = () => {
    if (scores.length === 0) return 0
    return Math.min(...scores)
  }
  
  const reset = () => {
    setGameState('ready')
    setReactionTime(null)
  }
  
  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold text-center mb-8">
        ⚡ 반응 속도 테스트
      </h1>
      
      {/* 게임 영역 */}
      <div
        onClick={handleClick}
        className={`h-64 rounded-lg flex items-center justify-center cursor-pointer transition-colors ${
          gameState === 'ready' ? 'bg-blue-500 hover:bg-blue-600' :
          gameState === 'waiting' ? 'bg-red-500' :
          gameState === 'click' ? 'bg-green-500' :
          'bg-gray-500'
        }`}
      >
        <div className="text-white text-center">
          {gameState === 'ready' && (
            <>
              <h2 className="text-2xl font-bold mb-2">클릭하여 시작</h2>
              <p>초록색이 되면 최대한 빨리 클릭하세요!</p>
            </>
          )}
          
          {gameState === 'waiting' && (
            <>
              <h2 className="text-2xl font-bold mb-2">기다리세요...</h2>
              <p>초록색이 될 때까지 기다리세요</p>
            </>
          )}
          
          {gameState === 'click' && (
            <h2 className="text-3xl font-bold">지금 클릭!</h2>
          )}
          
          {gameState === 'finished' && (
            <>
              <h2 className="text-3xl font-bold mb-2">
                {typeof reactionTime === 'number' 
                  ? `${reactionTime}ms` 
                  : reactionTime}
              </h2>
              <button
                onClick={(e) => {
                  e.stopPropagation()
                  reset()
                }}
                className="mt-4 bg-white text-blue-500 px-6 py-2 rounded hover:bg-gray-100"
              >
                다시 시도
              </button>
            </>
          )}
        </div>
      </div>
      
      {/* 버튼 */}
      {gameState === 'ready' && (
        <button
          onClick={startGame}
          className="w-full mt-6 bg-blue-500 hover:bg-blue-600 text-white py-3 rounded-lg font-bold transition-colors"
        >
          게임 시작
        </button>
      )}
      
      {/* 점수 */}
      <div className="mt-8 grid grid-cols-3 gap-4">
        <div className="bg-gray-100 p-4 rounded text-center">
          <p className="text-gray-600 text-sm">시도 횟수</p>
          <p className="text-2xl font-bold">{scores.length}</p>
        </div>
        <div className="bg-gray-100 p-4 rounded text-center">
          <p className="text-gray-600 text-sm">평균 속도</p>
          <p className="text-2xl font-bold">{getAverageScore()}ms</p>
        </div>
        <div className="bg-gray-100 p-4 rounded text-center">
          <p className="text-gray-600 text-sm">최고 기록</p>
          <p className="text-2xl font-bold">{getBestScore()}ms</p>
        </div>
      </div>
      
      {/* 기록 히스토리 */}
      {scores.length > 0 && (
        <div className="mt-6">
          <h3 className="font-bold mb-2">최근 기록</h3>
          <div className="flex gap-2 flex-wrap">
            {scores.slice(-10).map((score, index) => (
              <span
                key={index}
                className="bg-blue-100 text-blue-700 px-3 py-1 rounded text-sm"
              >
                {score}ms
              </span>
            ))}
          </div>
        </div>
      )}
    </div>
  )
}

커스텀 이벤트 훅 만들기

자주 사용하는 이벤트 로직을 커스텀 훅으로 만들 수 있습니다:

'use client'

import { useState, useEffect } from 'react'

// 커스텀 훅: 키보드 이벤트
function useKeyPress(targetKey) {
  const [keyPressed, setKeyPressed] = useState(false)
  
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === targetKey) {
        setKeyPressed(true)
      }
    }
    
    const handleKeyUp = (e) => {
      if (e.key === targetKey) {
        setKeyPressed(false)
      }
    }
    
    window.addEventListener('keydown', handleKeyDown)
    window.addEventListener('keyup', handleKeyUp)
    
    return () => {
      window.removeEventListener('keydown', handleKeyDown)
      window.removeEventListener('keyup', handleKeyUp)
    }
  }, [targetKey])
  
  return keyPressed
}

// 커스텀 훅: 마우스 위치
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 })
  
  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY })
    }
    
    window.addEventListener('mousemove', handleMouseMove)
    
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
    }
  }, [])
  
  return position
}

// 사용 예제
export default function CustomHooksExample() {
  const spacePressed = useKeyPress(' ')
  const enterPressed = useKeyPress('Enter')
  const mousePosition = useMousePosition()
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">커스텀 이벤트 훅</h2>
      
      <div className="space-y-4">
        <div className="border rounded p-4">
          <h3 className="font-bold mb-2">키보드 상태</h3>
          <p>스페이스바: {spacePressed ? '눌림 ✅' : '안눌림 ❌'}</p>
          <p>엔터키: {enterPressed ? '눌림 ✅' : '안눌림 ❌'}</p>
        </div>
        
        <div className="border rounded p-4">
          <h3 className="font-bold mb-2">마우스 위치</h3>
          <p>X: {mousePosition.x}px</p>
          <p>Y: {mousePosition.y}px</p>
        </div>
        
        <div 
          className="border rounded p-8 bg-gray-100 relative overflow-hidden"
          style={{ height: '200px' }}
        >
          <div
            className="absolute w-4 h-4 bg-red-500 rounded-full transform -translate-x-1/2 -translate-y-1/2"
            style={{
              left: `${(mousePosition.x / window.innerWidth) * 100}%`,
              top: `${(mousePosition.y / window.innerHeight) * 100}%`
            }}
          />
          <p className="text-center text-gray-600">
            마우스를 움직여보세요
          </p>
        </div>
      </div>
    </div>
  )
}

결론

이벤트 처리는 사용자와 웹 애플리케이션이 소통하는 창구입니다. 이번 챕터에서 우리는 React의 이벤트 시스템, 다양한 이벤트 타입, 이벤트 전파, 그리고 실제 활용 예제까지 다루었습니다.

중요한 것은 각 이벤트의 특성을 이해하고 적절한 상황에 올바른 이벤트를 사용하는 것입니다. onClick은 클릭에, onChange는 입력 변경에, onSubmit은 폼 제출에 사용하는 것처럼 말입니다. 또한 이벤트 버블링과 전파를 이해하면 더 효율적인 이벤트 처리가 가능합니다.

다음 챕터에서는 조건부 렌더링을 다루어, State와 이벤트를 활용하여 동적으로 UI를 변경하는 방법을 배워보겠습니다. 이벤트 처리와 조건부 렌더링을 결합하면 진정한 인터랙티브 웹 애플리케이션을 만들 수 있습니다!