[JavaScript] 로딩 스피너와 딤 레이어

개요

로딩 스피너와 딤 레이어를 설정하는 방법을 작성한 글.

ℹ️ 로딩 스피너(spinner)란 로딩 중임을 표현하기 위한 뱅뱅 도는 어떤 이미지를 의미한다. spinner images로 검색해 볼 것.

코드

#loading {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #ffffff36;
  z-index: 9999;
  background-image: url("/image/loading.gif");
  background-repeat: no-repeat;
  background-attachment: fixed;
  background-position: center;
}
<div id="loading"></div>
function onLoad() {
  let $ele = document.querySelector('#loading');
  $ele.style = 'display: block';
}

function onLoaded() {
  let $ele = document.querySelector('#loading');
  $ele.style = 'display: none';
}

살짝 흐릿한 레이어가 전면을 덮어서 로딩이 완료될 때까지 클릭 등을 막는 용도로 쓴다.

여기에 대충 적당히 움직이는 이미지를 얹으면 끗.

개선 버전: 다중 비동기 작업 지원

카운터 방식으로 동시 진행 중인 모든 작업을 추적하고 마지막 작업 완료 시에만 로딩이 종료된다.

/**
 * 동시 다중 비동기 작업을 지원하는 로딩 오버레이 (카운터 기반)
 */
class LoadingOverlay {
  constructor() {
    this.overlay = null;
    this.loadingCount = 0;  // 현재 진행 중인 작업 수
    this.previousActiveElement = null;
    this.currentMessage = '처리 중입니다...';
    this.createOverlay();
    this.initEventListeners();
  }

  createOverlay() {
    this.overlay = document.createElement('div');
    this.overlay.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.5);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
      opacity: 0;
      visibility: hidden;
      transition: opacity 0.3s ease, visibility 0.3s ease;
    `;
    
    this.overlay.innerHTML = `
      <div style="
        background: white;
        padding: 2rem;
        border-radius: 8px;
        text-align: center;
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
        max-width: 300px;
        width: 90%;
      ">
        <div style="
          width: 40px;
          height: 40px;
          border: 3px solid #f3f3f3;
          border-top: 3px solid #007bff;
          border-radius: 50%;
          animation: spin 1s linear infinite;
          margin: 0 auto 1rem;
        "></div>
        <p style="color: #666; font-size: 1rem; margin: 0;" class="loading-text">
          처리 중입니다...
        </p>
        <div style="color: #999; font-size: 0.8rem; margin-top: 0.5rem;" class="loading-counter">
          진행 중: 1개
        </div>
      </div>
    `;

    // 스피너 애니메이션 추가
    const style = document.createElement('style');
    style.textContent = `
      @keyframes spin {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
      }
    `;
    document.head.appendChild(style);
    document.body.appendChild(this.overlay);
  }

  initEventListeners() {
    // 키보드 입력 차단
    document.addEventListener('keydown', this.blockInput.bind(this));
    
    // 오버레이 클릭 차단
    this.overlay.addEventListener('click', (e) => {
      e.stopPropagation();
      e.preventDefault();
    });
  }

  show(message = '처리 중입니다...') {
    this.loadingCount++;
    
    // 첫 번째 작업이 시작될 때만 포커스 저장
    if (this.loadingCount === 1) {
      this.previousActiveElement = document.activeElement;
      this.overlay.style.opacity = '1';
      this.overlay.style.visibility = 'visible';
      document.body.style.overflow = 'hidden';
    }

    // 메시지와 카운터 업데이트
    this.updateDisplay(message);
    
    console.log(`[LoadingOverlay] 작업 시작 (${this.loadingCount}개 진행 중): ${message}`);
  }

  hide() {
    if (this.loadingCount <= 0) {
      console.warn('[LoadingOverlay] hide() 호출되었지만 진행 중인 작업이 없습니다.');
      return;
    }

    this.loadingCount--;
    console.log(`[LoadingOverlay] 작업 완료 (${this.loadingCount}개 남음)`);

    // 모든 작업이 완료되었을 때만 오버레이 숨김
    if (this.loadingCount === 0) {
      this.overlay.style.opacity = '0';
      this.overlay.style.visibility = 'hidden';
      document.body.style.overflow = '';
      
      // 포커스 복원
      if (this.previousActiveElement && this.previousActiveElement.focus) {
        this.previousActiveElement.focus();
      }
      
      console.log('[LoadingOverlay] 모든 작업 완료, 오버레이 숨김');
    } else {
      // 아직 진행 중인 작업이 있으면 카운터만 업데이트
      this.updateDisplay();
    }
  }

  updateDisplay(newMessage = null) {
    if (newMessage) {
      this.currentMessage = newMessage;
    }

    const loadingText = this.overlay.querySelector('.loading-text');
    const loadingCounter = this.overlay.querySelector('.loading-counter');
    
    loadingText.textContent = this.currentMessage;
    loadingCounter.textContent = `진행 중: ${this.loadingCount}개`;
  }

  forceReset() {
    console.warn('[LoadingOverlay] 강제 리셋 실행');
    this.loadingCount = 0;
    this.overlay.style.opacity = '0';
    this.overlay.style.visibility = 'hidden';
    document.body.style.overflow = '';
  }

  blockInput(e) {
    if (this.loadingCount === 0) return;
    
    // F5, F12, ESC 등 일부 키는 허용
    const allowedKeys = ['F5', 'F12', 'Escape'];
    if (!allowedKeys.includes(e.key)) {
      e.preventDefault();
      e.stopPropagation();
    }
  }

  isLoading() {
    return this.loadingCount > 0;
  }

  getLoadingCount() {
    return this.loadingCount;
  }
}
const loadingOverlay = new LoadingOverlay();

사용 패턴

수동 관리

try {
  loadingOverlay.show('작업1 시작');
  loadingOverlay.show('작업2 시작'); // 카운터: 2
  
  await task1();
  loadingOverlay.hide(); // 카운터: 1
  
  await task2();
  loadingOverlay.hide(); // 카운터: 0, 오버레이 숨김
} catch (error) {
  loadingOverlay.forceReset(); // 에러시 강제 리셋
}

자동 관리

// 자동으로 show/hide를 관리하는 래퍼 함수
async function withLoading(asyncFn, message = '처리 중입니다...') {
  try {
    loadingOverlay.show(message);
    return await asyncFn();
  } finally {
    loadingOverlay.hide();
  }
}
// 단일 작업
await withLoading(fetchData, '데이터 로딩 중...');

// 동시 다중 작업
await Promise.all([
  withLoading(task1, '작업1 중...'),
  withLoading(task2, '작업2 중...'),
  withLoading(task3, '작업3 중...')
]);

동시 실행 예시:

async function fetchUserData() {
  return withLoading(async () => {
    const response = await fetch('/api/users');
    return response.json();
  }, '사용자 데이터 로딩 중...');
}

async function uploadFile(file) {
  return withLoading(async () => {
    const formData = new FormData();
    formData.append('file', file);
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    });
    return response.json();
  }, '파일 업로드 중...');
}

async function handleMultipleOperations() {
  // 이 세 작업이 동시에 실행되어도 오버레이가 올바르게 관리됨
  const [userData, fileResult, otherData] = await Promise.all([
    fetchUserData(),
    uploadFile(someFile),
    withLoading(() => fetch('/api/other').then(r => r.json()), '기타 데이터 로딩...')
  ]);
  
  return { userData, fileResult, otherData };
}

폼 제출 시 사용 예시:

document.addEventListener('DOMContentLoaded', () => {
  const form = document.getElementById('myForm');
  if (form) {
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      
      try {
        await withLoading(async () => {
          // 실제 폼 제출 로직
          await new Promise(resolve => setTimeout(resolve, 2000));
        }, '폼 전송 중...');
        
        alert('제출 완료!');
      } catch (error) {
        alert('제출 실패: ' + error.message);
      }
    });
  }
});