ASP.NET Core Carousel 뷰 컴포넌트 만들기

  • 25 minutes to read

이 실습에서는 이미지 슬라이드 카루셀을 ViewComponent로 제작하여, 메인 페이지에 조건적으로 표시하는 방법을 학습합니다. 슬라이드는 자동으로 넘겨지며, 사용자가 직접 탐색하거나 일시정지할 수도 있습니다.

전체 소스 코드는 다음 링크를 참고하세요.

https://github.com/VisualAcademy/DotNetNote


Step 1: 프로젝트 구조 확인 및 정리

카루셀 관련 파일은 다음 위치에 배치합니다.

📁 wwwroot
├── 📁 css
│   └── az-carousel.css
├── 📁 js
│   └── az-carousel.js

📁 Views
└── 📁 Shared
    └── 📁 Components
        └── 📁 AzCarousel
            └── Default.cshtml

📁 ViewComponents
└── AzCarouselViewComponent.cs

Step 2: CSS 스타일 추가

wwwroot/css/az-carousel.css 파일을 만들고 다음 코드를 추가합니다.

.az-carousel-section {
    position: relative;
    width: 100%;
    height: 500px;
    overflow: hidden;
}

.az-slides {
    position: relative;
    width: 100%;
    height: 100%;
}

.az-slide {
    position: absolute;
    width: 100%;
    height: 100%;
    opacity: 0;
    transition: opacity 0.8s ease-in-out;
}

    .az-slide.az-active {
        opacity: 1;
        z-index: 1;
    }

    .az-slide img {
        width: 100%;
        height: 100%;
        object-fit: cover;
    }

.az-slide-content {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: rgba(0, 0, 0, 0.5);
    padding: 20px;
    color: #fff;
    text-align: center;
    border-radius: 8px;
    max-width: 80%;
}

    .az-slide-content h2 {
        font-size: 1.8rem;
        margin-bottom: 10px;
    }

    .az-slide-content p {
        font-size: 1rem;
        margin-bottom: 15px;
    }

    .az-slide-content a {
        color: white;
        background-color: #007bff;
        padding: 8px 16px;
        text-decoration: none;
        border-radius: 4px;
    }

        .az-slide-content a:hover {
            background-color: #0056b3;
        }

.az-nav-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    font-size: 1.8rem;
    color: white;
    background: rgba(0, 0, 0, 0.5);
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    cursor: pointer;
    z-index: 10;
}

    .az-nav-arrow.az-left {
        left: 20px;
    }

    .az-nav-arrow.az-right {
        right: 20px;
    }

.az-progress-indicators {
    position: absolute;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 8px;
    align-items: center;
    z-index: 20;
}

.az-progress-dot {
    width: 40px;
    height: 4px;
    background: rgba(255, 255, 255, 0.3);
    border-radius: 2px;
    overflow: hidden;
    position: relative;
    cursor: pointer;
}

.az-progress-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 0%;
    background: #007aff;
}

.az-pause-button {
    margin-left: 12px;
    font-size: 1.1rem;
    cursor: pointer;
    color: white;
    background: rgba(0,0,0,0.5);
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
}

    .az-pause-button:hover {
        background: rgba(0,0,0,0.7);
    }

Step 3: 자바스크립트 추가

wwwroot/js/az-carousel.js 파일을 만들고 다음 코드를 작성합니다.

const azSlides = document.querySelectorAll('.az-slide');
const azTotalSlides = azSlides.length;
const azProgressIndicators = document.getElementById('az-progress-indicators');
const azSlideDuration = 5000;
let azCurrentIndex = 0;
let azIsPaused = false;
let azTimeout;
let azStartTime;
let azElapsed = 0;

const azProgressDots = [];

for (let i = 0; i < azTotalSlides; i++) {
    const dot = document.createElement('div');
    dot.className = 'az-progress-dot';
    dot.dataset.index = i;

    const fill = document.createElement('div');
    fill.className = 'az-progress-fill';
    dot.appendChild(fill);

    dot.addEventListener('click', () => {
        clearTimeout(azTimeout);
        azElapsed = 0;
        azUpdateSlide(i);
    });

    azProgressIndicators.appendChild(dot);
    azProgressDots.push(fill);
}

const azPauseBtn = document.createElement('div');
azPauseBtn.className = 'az-pause-button';
azPauseBtn.innerHTML = '<i class="fas fa-pause"></i>';
azProgressIndicators.appendChild(azPauseBtn);

azPauseBtn.addEventListener('click', () => {
    azIsPaused = !azIsPaused;
    azPauseBtn.innerHTML = `<i class="fas fa-${azIsPaused ? 'play' : 'pause'}"></i>`;
    if (azIsPaused) {
        azPauseProgress();
    } else {
        azResumeProgress();
    }
});

function azUpdateSlide(index) {
    azCurrentIndex = (index + azTotalSlides) % azTotalSlides;

    azSlides.forEach((slide, i) => {
        slide.classList.toggle('az-active', i === azCurrentIndex);
    });

    azProgressDots.forEach(dot => {
        dot.style.transition = 'none';
        dot.style.width = '0%';
    });

    azElapsed = 0;
    if (!azIsPaused) azStartProgress(azSlideDuration);
}

function azStartProgress(duration) {
    const dot = azProgressDots[azCurrentIndex];
    dot.style.transition = 'none';
    dot.style.width = '0%';

    requestAnimationFrame(() => {
        dot.style.transition = `width ${duration}ms linear`;
        dot.style.width = '100%';
    });

    azStartTime = Date.now();
    azTimeout = setTimeout(() => {
        azElapsed = 0;
        azUpdateSlide(azCurrentIndex + 1);
    }, duration);
}

function azPauseProgress() {
    const dot = azProgressDots[azCurrentIndex];
    const computed = parseFloat(getComputedStyle(dot).width);
    const parentWidth = dot.parentElement.offsetWidth;
    const percent = (computed / parentWidth) * 100;

    dot.style.transition = 'none';
    dot.style.width = percent + '%';

    azElapsed += Date.now() - azStartTime;
    clearTimeout(azTimeout);
}

function azResumeProgress() {
    const remaining = azSlideDuration - azElapsed;
    azStartProgress(remaining);
}

document.getElementById('az-next-btn').addEventListener('click', () => {
    clearTimeout(azTimeout);
    azElapsed = 0;
    azUpdateSlide(azCurrentIndex + 1);
});

document.getElementById('az-prev-btn').addEventListener('click', () => {
    clearTimeout(azTimeout);
    azElapsed = 0;
    azUpdateSlide(azCurrentIndex - 1);
});

const azCarousel = document.getElementById('az-carousel');
let azTouchStartX = 0;
let azTouchEndX = 0;

azCarousel.addEventListener('touchstart', e => {
    azTouchStartX = e.changedTouches[0].screenX;
});

azCarousel.addEventListener('touchend', e => {
    azTouchEndX = e.changedTouches[0].screenX;
    azHandleSwipe();
});

function azHandleSwipe() {
    if (azTouchEndX < azTouchStartX - 30) {
        clearTimeout(azTimeout);
        azElapsed = 0;
        azUpdateSlide(azCurrentIndex + 1);
    }
    if (azTouchEndX > azTouchStartX + 30) {
        clearTimeout(azTimeout);
        azElapsed = 0;
        azUpdateSlide(azCurrentIndex - 1);
    }
}

document.addEventListener('DOMContentLoaded', () => {
    azUpdateSlide(0);
});

이 스크립트는 자동 진행, 터치 슬라이드, 이전/다음 버튼 및 일시정지 기능을 포함합니다.


Step 4: ViewComponent 생성

ViewComponents/AzCarouselViewComponent.cs 파일 생성

namespace DotNetNote.ViewComponents;

public class AzCarouselViewComponent : ViewComponent
{
    public IViewComponentResult Invoke() => View();
}

AzCarousel이라는 이름으로 호출할 수 있는 뷰 컴포넌트입니다.


Step 5: ViewComponent 뷰 작성

Views/Shared/Components/AzCarousel/Default.cshtml 파일 생성 후 다음을 작성합니다.

@* AzCarousel View *@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link href="/css/az-carousel.css" rel="stylesheet" />

<div class="container px-0">
    <section class="az-carousel-section" id="az-carousel">
        <div class="az-slides" id="az-slide-container">
            <div class="az-slide az-active">
                <img src="https://images.pexels.com/photos/1103970/pexels-photo-1103970.jpeg?auto=compress&cs=tinysrgb&w=1600" alt="Slide 1" />
                <div class="az-slide-content">
                    <h2>Blazor Server Part 1</h2>
                    <p>회사 홈페이지와 관리자 페이지를 Blazor로 만드는 실전 웹개발 과정</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4369" target="_blank">강의 보기</a>
                </div>
            </div>
            <div class="az-slide">
                <img src="https://images.pexels.com/photos/18105/pexels-photo.jpg?auto=compress&cs=tinysrgb&w=1600" alt="Slide 2" />
                <div class="az-slide-content">
                    <h2>Blazor 게시판 프로젝트 Part 2</h2>
                    <p>공지사항, 자료실, 답변형 게시판을 Blazor로 직접 구현해보세요</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4370" target="_blank">강의 보기</a>
                </div>
            </div>
            <div class="az-slide">
                <img src="https://images.pexels.com/photos/414612/pexels-photo-414612.jpeg?auto=compress&cs=tinysrgb&w=1600" alt="Slide 3" />
                <div class="az-slide-content">
                    <h2>Blazor 실전 프로젝트 Part 3</h2>
                    <p>hawaso.com 사이트를 직접 구현하며 실무 핵심 기능을 익히는 강의</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4371" target="_blank">강의 보기</a>
                </div>
            </div>
        </div>

        <div class="az-nav-arrow az-left" id="az-prev-btn"><i class="fas fa-chevron-left"></i></div>
        <div class="az-nav-arrow az-right" id="az-next-btn"><i class="fas fa-chevron-right"></i></div>

        <div class="az-progress-indicators" id="az-progress-indicators"></div>
    </section>
</div>

<script src="/js/az-carousel.js"></script>

필요한 만큼 .az-slide를 추가해 자유롭게 슬라이드를 구성하세요.


Step 6: 메인 페이지에 적용

Views/Home/Index.cshtml에서 카루셀을 조건부로 출력합니다.

@if (DateTime.Now.Second % 2 == 0)
{
    @await Component.InvokeAsync("AzCarousel")    
}
else
{
    <!-- 짝수가 아닌 경우 아무것도 출력하지 않음 -->
}

DateTime.Now.Second % 2 == 0 조건을 통해 짝수 초일 때만 표시되도록 했습니다. 테스트용 조건입니다. 실전에서는 사용자 정보, 로그인 여부 등으로 제어하세요.


실행 결과

프로젝트를 실행하면 다음과 같은 기능을 갖춘 카루셀이 메인 페이지에 표시됩니다:

  • 이미지 슬라이드 자동 전환
  • 이전/다음 화살표 버튼
  • 슬라이드 진행 상태 표시
  • 슬라이드 클릭으로 직접 이동
  • 일시정지/재개 버튼
  • 모바일 터치 스와이프 지원

마무리

이제 ASP.NET Core MVC 프로젝트에서 커스터마이즈 가능한 ViewComponent 기반 카루셀을 적용할 수 있게 되었습니다. 컴포넌트 구조로 제작했기 때문에 유지보수와 재사용도 간편합니다.


개선

위 내용까지의 강좌를 기반으로 홈페이지 메인에 카루셀 기능을 약 1년간 운영했습니다.

기본 동작에는 문제가 없었지만, 카루셀이 실행 중인 페이지가 브라우저에서 다른 탭이나 다른 프로그램으로 전환되어 화면에서 보이지 않게 된 후 다시 돌아왔을 때, 프로그레스바가 여러 개 동시에 진행되는 현상이 발생했습니다.

이 문제를 해결하기 위해 카루셀의 동작 구조를 개선하였고, 아래의 3개 파일을 교체하면 해당 버그 없이 정상적으로 동작하는 것을 확인할 수 있습니다.

DotNetNote\wwwroot\css\az-carousel.css

/* ------------------------------------------------------------
   Az Carousel Layout Styles
   이 파일은 카루셀의 전체 레이아웃, 슬라이드 전환,
   네비게이션 버튼, 진행바 UI를 정의합니다.
------------------------------------------------------------- */

/* 카루셀 전체 영역 */
.az-carousel-section {
    position: relative; /* 내부 절대 위치 요소들의 기준 */
    width: 100%; /* 부모 너비에 맞춤 */
    height: 500px; /* 카루셀 고정 높이 */
    overflow: hidden; /* 슬라이드 전환 시 영역 밖 요소 숨김 */
}

/* 슬라이드들을 감싸는 컨테이너 */
.az-slides {
    position: relative; /* 슬라이드들이 absolute로 배치될 기준 */
    width: 100%;
    height: 100%;
}

/* 개별 슬라이드 */
.az-slide {
    position: absolute; /* 모든 슬라이드를 겹쳐 놓음 */
    width: 100%;
    height: 100%;
    opacity: 0; /* 기본은 숨김 */
    transition: opacity 0.8s ease-in-out; /* 페이드 전환 효과 */
}

    /* 현재 활성화된 슬라이드 */
    .az-slide.az-active {
        opacity: 1; /* 보이도록 처리 */
        z-index: 1; /* 다른 슬라이드 위에 표시 */
    }

    /* 슬라이드 배경 이미지 */
    .az-slide img {
        width: 100%;
        height: 100%;
        object-fit: cover; /* 영역을 꽉 채우되 비율 유지 */
    }

/* 슬라이드 위 텍스트 박스 */
.az-slide-content {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%); /* 정중앙 배치 */
    background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경 */
    padding: 20px;
    color: #fff;
    text-align: center;
    border-radius: 8px;
    max-width: 80%;
}

    /* 제목 스타일 */
    .az-slide-content h2 {
        font-size: 1.8rem;
        margin-bottom: 10px;
    }

    /* 설명 텍스트 스타일 */
    .az-slide-content p {
        font-size: 1rem;
        margin-bottom: 15px;
    }

    /* 버튼 스타일 */
    .az-slide-content a {
        color: white;
        background-color: #007bff;
        padding: 8px 16px;
        text-decoration: none;
        border-radius: 4px;
    }

        /* 버튼 hover 효과 */
        .az-slide-content a:hover {
            background-color: #0056b3;
        }

/* 좌우 네비게이션 화살표 공통 스타일 */
.az-nav-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%); /* 세로 중앙 정렬 */
    font-size: 1.8rem;
    color: white;
    background: rgba(0, 0, 0, 0.5);
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    cursor: pointer;
    z-index: 10; /* 슬라이드 위에 표시 */
}

    /* 왼쪽 화살표 위치 */
    .az-nav-arrow.az-left {
        left: 20px;
    }

    /* 오른쪽 화살표 위치 */
    .az-nav-arrow.az-right {
        right: 20px;
    }

/* 진행바 및 일시정지 버튼 영역 */
.az-progress-indicators {
    position: absolute;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%); /* 하단 중앙 배치 */
    display: flex;
    gap: 8px; /* dot 간 간격 */
    align-items: center;
    z-index: 20;
}

/* 개별 진행바(dot) */
.az-progress-dot {
    width: 40px;
    height: 4px;
    background: rgba(255, 255, 255, 0.3); /* 비활성 배경 */
    border-radius: 2px;
    overflow: hidden; /* 내부 fill이 넘치지 않도록 */
    position: relative;
    cursor: pointer;
}

/* 실제 진행률을 나타내는 막대 */
.az-progress-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 0%; /* JS에서 애니메이션으로 증가 */
    background: #007aff;
}

/* 일시정지 / 재생 버튼 */
.az-pause-button {
    margin-left: 12px;
    font-size: 1.1rem;
    cursor: pointer;
    color: white;
    background: rgba(0,0,0,0.5);
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
}

    /* 일시정지 버튼 hover 효과 */
    .az-pause-button:hover {
        background: rgba(0,0,0,0.7);
    }

DotNetNote\wwwroot\js\az-carousel.js

/**
 * Az Carousel Script
 *
 * 이 스크립트는 .az-carousel-section 단위로 카루셀을 초기화합니다.
 * 한 페이지에 여러 개의 카루셀이 존재해도 서로 영향을 주지 않도록
 * 각 섹션을 독립된 개체로 동작시키는 구조입니다.
 *
 * [필수 HTML 구성]
 * - .az-carousel-section         : 카루셀 전체 영역
 * - .az-slide                    : 슬라이드 요소들
 * - .az-progress-indicators      : 진행바 및 일시정지 버튼 영역 (스크립트가 내부 요소 생성)
 * - .az-prev-btn / .az-next-btn  : 이전 / 다음 버튼
 *
 * [동작 개요]
 * - 현재 슬라이드에만 .az-active 클래스가 적용됩니다.
 * - 슬라이드 수만큼 진행바(dot)가 자동 생성됩니다.
 * - 진행 상태는 width 애니메이션으로 표현됩니다.
 * - 사용자가 dot 클릭, 이전/다음 버튼 클릭, 스와이프 동작 시
 *   현재 진행 중인 타이머와 애니메이션 예약을 정리한 뒤 즉시 전환합니다.
 *
 * [탭 전환 처리]
 * - 브라우저 탭이 숨겨지면 자동으로 재생을 멈춥니다.
 * - 탭이 다시 보이면 남은 시간만큼 이어서 재생합니다.
 * - 사용자가 직접 일시정지를 누른 상태는 자동 제어의 영향을 받지 않습니다.
 *
 * [구조적 특징]
 * - requestAnimationFrame 예약을 개체 단위로 관리하여 중복 실행을 방지합니다.
 * - transition 초기화 후 reflow를 발생시켜 애니메이션이 안정적으로 시작되도록 합니다.
 * - 모든 DOM 접근은 섹션 기준으로 수행되며, 전역 ID에 의존하지 않습니다.
 *
 * [설정 가능 항목]
 * - SLIDE_DURATION 값을 변경하면 슬라이드 자동 전환 시간을 조절할 수 있습니다.
 */

(function () {

    // 슬라이드 한 장의 자동 재생 시간(ms)
    const SLIDE_DURATION = 5000;

    // 페이지 내 모든 카루셀 섹션을 찾음
    const sections = document.querySelectorAll('.az-carousel-section');
    if (!sections || sections.length === 0) return;

    // 각 카루셀 개체를 저장 (탭 가시성 변경 시 전체 제어용)
    const instances = [];

    // ------------------------------------------------------------
    // 섹션별로 독립 개체 생성(인스턴스화)
    // ------------------------------------------------------------
    sections.forEach(section => {

        // 현재 섹션 안의 슬라이드 목록
        const slides = section.querySelectorAll('.az-slide');
        const totalSlides = slides.length;

        // 진행바 영역 및 컨트롤 버튼(이전/다음)
        const progressIndicators = section.querySelector('.az-progress-indicators');
        const nextBtn = section.querySelector('.az-next-btn');
        const prevBtn = section.querySelector('.az-prev-btn');

        // 필수 DOM이 없거나 슬라이드가 0개면 동작 불가
        if (!progressIndicators || !nextBtn || !prevBtn || totalSlides === 0) return;

        // 서버 렌더링/재호출로 dot이 남아있을 수 있으니 초기화
        progressIndicators.innerHTML = '';

        // --------------------------------------------------------
        // 카루셀 "개체" 상태 관리용 데이터 묶음
        // --------------------------------------------------------
        const state = {
            section,
            slides,
            totalSlides,
            progressIndicators,
            nextBtn,
            prevBtn,

            // 현재 보고 있는 슬라이드 인덱스
            currentIndex: 0,

            // 사용자가 수동으로 일시정지 눌렀는지 여부
            isPaused: false,

            // 탭 숨김으로 인해 자동 일시정지 되었는지 여부
            autoPausedByVisibility: false,

            // 자동 전환 타이머(setTimeout) 핸들
            timeoutId: null,

            // 진행 애니메이션 예약(requestAnimationFrame) 핸들
            rafId: null,

            // 진행률 계산을 위한 시작 시각/누적 경과시간
            startTime: 0,
            elapsed: 0,

            // 슬라이드 재생 시간
            slideDuration: SLIDE_DURATION,

            // 진행바 fill 요소들(.az-progress-fill)
            progressFills: [],

            // 일시정지 버튼 DOM
            pauseBtn: null
        };

        // --------------------------------------------------------
        // 진행바(dot) 생성
        // - 슬라이드 개수만큼 생성
        // - 각 dot 클릭 시 해당 슬라이드로 이동
        // --------------------------------------------------------
        for (let i = 0; i < totalSlides; i++) {
            const dot = document.createElement('div');
            dot.className = 'az-progress-dot';
            dot.dataset.index = i;

            const fill = document.createElement('div');
            fill.className = 'az-progress-fill';
            dot.appendChild(fill);

            // dot 클릭 -> 해당 슬라이드로 즉시 이동
            dot.addEventListener('click', () => {
                // 기존 예약 정리 후 이동(중복 애니메이션 방지)
                clearTimeout(state.timeoutId);
                cancelRaf(state);

                // 새 슬라이드로 이동하므로 경과시간 초기화
                state.elapsed = 0;

                updateSlide(state, i);
            });

            progressIndicators.appendChild(dot);
            state.progressFills.push(fill);
        }

        // --------------------------------------------------------
        // 일시정지 버튼 생성
        // - 클릭 시 pause / resume 토글
        // --------------------------------------------------------
        const pauseBtn = document.createElement('div');
        pauseBtn.className = 'az-pause-button';
        pauseBtn.innerHTML = '<i class="fas fa-pause"></i>';
        progressIndicators.appendChild(pauseBtn);
        state.pauseBtn = pauseBtn;

        pauseBtn.addEventListener('click', () => {
            state.isPaused = !state.isPaused;

            // 상태에 따라 아이콘 변경
            pauseBtn.innerHTML = `<i class="fas fa-${state.isPaused ? 'play' : 'pause'}"></i>`;

            // 실제 진행 제어
            if (state.isPaused) {
                pauseProgress(state);
            } else {
                resumeProgress(state);
            }
        });

        // --------------------------------------------------------
        // 다음/이전 버튼 처리
        // - 버튼 클릭 시 기존 예약 정리 후 즉시 전환
        // --------------------------------------------------------
        nextBtn.addEventListener('click', () => {
            clearTimeout(state.timeoutId);
            cancelRaf(state);
            state.elapsed = 0;
            updateSlide(state, state.currentIndex + 1);
        });

        prevBtn.addEventListener('click', () => {
            clearTimeout(state.timeoutId);
            cancelRaf(state);
            state.elapsed = 0;
            updateSlide(state, state.currentIndex - 1);
        });

        // --------------------------------------------------------
        // 모바일 스와이프 처리
        // - 좌/우 스와이프 감지 후 슬라이드 전환
        // --------------------------------------------------------
        let touchStartX = 0;
        let touchEndX = 0;

        section.addEventListener('touchstart', e => {
            touchStartX = e.changedTouches[0].screenX;
        });

        section.addEventListener('touchend', e => {
            touchEndX = e.changedTouches[0].screenX;
            handleSwipe(state, touchStartX, touchEndX);
        });

        // --------------------------------------------------------
        // 초기 시작: 0번 슬라이드 표시 및 진행바 시작
        // --------------------------------------------------------
        updateSlide(state, 0);

        // 탭 가시성 변경 이벤트에서 전체 제어하기 위해 등록
        instances.push(state);
    });

    // ------------------------------------------------------------
    // 탭 가시성(visibility) 변경 처리
    // - 숨김: 자동 일시정지
    // - 복귀: 자동 재개(단, 사용자가 수동 pause 중이면 재개하지 않음)
    // ------------------------------------------------------------
    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            instances.forEach(state => {
                // 사용자가 수동으로 멈춘 상태는 건드리지 않음
                if (!state.isPaused) {
                    state.autoPausedByVisibility = true;
                    pauseProgress(state);
                }
            });
        } else {
            instances.forEach(state => {
                // 탭 숨김 때문에 멈춘 것만 복구
                if (state.autoPausedByVisibility) {
                    state.autoPausedByVisibility = false;

                    // 사용자가 수동 pause 상태가 아니라면 이어서 재생
                    if (!state.isPaused) {
                        resumeProgress(state);
                    }
                }
            });
        }
    });

    // ============================================================
    // 아래부터는 "동작 함수" 영역
    // ============================================================

    /**
     * requestAnimationFrame 예약이 있으면 취소한다.
     * - 탭 숨김 상태에서 rAF가 누적되었다가 한꺼번에 실행되는 현상을 방지한다.
     */
    function cancelRaf(state) {
        if (state.rafId) {
            cancelAnimationFrame(state.rafId);
            state.rafId = null;
        }
    }

    /**
     * 슬라이드를 전환한다.
     * - index를 범위 내로 보정(mod 처리)
     * - .az-active 클래스를 현재 슬라이드에만 적용
     * - 모든 진행바를 초기화하고, 필요 시 현재 슬라이드 진행을 시작한다.
     */
    function updateSlide(state, index) {
        // index를 0 ~ totalSlides-1 범위로 맞춘다.
        state.currentIndex = (index + state.totalSlides) % state.totalSlides;

        // 현재 슬라이드만 active 처리
        state.slides.forEach((slide, i) => {
            slide.classList.toggle('az-active', i === state.currentIndex);
        });

        // 진행바 초기화(모두 0%로)
        state.progressFills.forEach(fill => {
            fill.style.transition = 'none';
            fill.style.width = '0%';
        });

        // 이전 예약 정리(중복 진행 방지)
        clearTimeout(state.timeoutId);
        cancelRaf(state);

        // 새 슬라이드이므로 경과시간 초기화
        state.elapsed = 0;

        // 일시정지 상태가 아니고, 탭이 보이는 상태면 진행 시작
        if (!state.isPaused && !document.hidden) {
            startProgress(state, state.slideDuration);
        }
    }

    /**
     * 현재 슬라이드의 진행바를 시작한다.
     * - width 0% -> 100% 를 duration 동안 linear로 증가
     * - duration이 끝나면 자동으로 다음 슬라이드로 넘어간다.
     */
    function startProgress(state, duration) {
        const fill = state.progressFills[state.currentIndex];
        if (!fill) return;

        // 진행바 초기화
        fill.style.transition = 'none';
        fill.style.width = '0%';

        // transition 초기화가 확실히 적용되도록 reflow를 발생시킨다.
        // (브라우저가 스타일 변경을 적용하도록 강제)
        void fill.offsetWidth;

        // rAF 누적 방지 후 다음 프레임에서 transition 시작
        cancelRaf(state);
        state.rafId = requestAnimationFrame(() => {
            fill.style.transition = `width ${duration}ms linear`;
            fill.style.width = '100%';
        });

        // 진행 시작 시각 기록
        state.startTime = Date.now();

        // duration 후 자동 다음 슬라이드로 전환
        state.timeoutId = setTimeout(() => {
            state.elapsed = 0;
            updateSlide(state, state.currentIndex + 1);
        }, duration);
    }

    /**
     * 진행바를 현재 진행 위치에서 멈춘다.
     * - getComputedStyle로 현재 fill의 width(px)를 얻는다.
     * - 부모(dot) width 대비 퍼센트를 계산하여 그 위치에서 고정한다.
     * - 경과시간(elapsed)을 누적하여 resume 시 남은 시간을 계산할 수 있게 한다.
     */
    function pauseProgress(state) {
        const fill = state.progressFills[state.currentIndex];
        if (!fill) return;

        // 현재 fill의 픽셀 폭
        const computedWidth = parseFloat(getComputedStyle(fill).width) || 0;

        // 부모 폭 대비 퍼센트 계산
        const parentWidth = fill.parentElement ? fill.parentElement.offsetWidth : 0;
        const percent = parentWidth > 0 ? (computedWidth / parentWidth) * 100 : 0;

        // 현재 퍼센트 위치로 고정
        fill.style.transition = 'none';
        fill.style.width = Math.max(0, Math.min(100, percent)) + '%';

        // 경과시간 누적
        if (state.startTime) {
            state.elapsed += Date.now() - state.startTime;
        }

        // 자동 전환 예약 제거
        clearTimeout(state.timeoutId);
        cancelRaf(state);
    }

    /**
     * 멈춰있던 진행바를 이어서 진행한다.
     * - elapsed(누적 경과시간)를 기준으로 remaining(남은시간)을 계산한다.
     * - 탭이 숨겨진 상태면 resume 하지 않는다(복귀 시 visibilitychange가 처리).
     */
    function resumeProgress(state) {
        if (document.hidden) return;

        const remaining = Math.max(0, state.slideDuration - state.elapsed);

        // remaining이 0이면 한 사이클로 다시 시작
        startProgress(state, remaining > 0 ? remaining : state.slideDuration);
    }

    /**
     * 스와이프 동작으로 슬라이드를 전환한다.
     * - 좌측 스와이프: 다음 슬라이드
     * - 우측 스와이프: 이전 슬라이드
     * - 전환 전 기존 예약을 정리하고 경과시간을 초기화한다.
     */
    function handleSwipe(state, startX, endX) {
        // 좌측 스와이프(다음)
        if (endX < startX - 30) {
            clearTimeout(state.timeoutId);
            cancelRaf(state);
            state.elapsed = 0;
            updateSlide(state, state.currentIndex + 1);
        }
        // 우측 스와이프(이전)
        else if (endX > startX + 30) {
            clearTimeout(state.timeoutId);
            cancelRaf(state);
            state.elapsed = 0;
            updateSlide(state, state.currentIndex - 1);
        }
    }

})();

DotNetNote\Views\Shared\Components\AzCarousel\Default.cshtml

@* ------------------------------------------------------------
   AzCarousel Component Markup
   이 뷰는 슬라이드형 배너(카루셀)를 구성하는 전체 HTML 구조입니다.
   포함된 모든 슬라이드가 항상 함께 출력되는 기본 버전입니다.
   동작은 /js/az-carousel.js 스크립트가 담당합니다.
------------------------------------------------------------- *@

@* 아이콘(좌우 화살표, 재생/일시정지 아이콘 등)에 사용되는 Font Awesome *@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />

@* 카루셀 전용 스타일 파일 *@
<link href="/css/az-carousel.css" rel="stylesheet" />

<div class="container px-0">
    <!-- 카루셀 전체 영역: 스크립트가 이 section 하나를 하나의 개체로 인식 -->
    <section class="az-carousel-section">

        <!-- 슬라이드들을 감싸는 컨테이너 -->
        <div class="az-slides">

            <!-- 첫 번째 슬라이드: 초기 로딩 시 표시되도록 az-active 클래스 포함 -->
            <div class="az-slide az-active">
                <img src="~/images/az-carousel/blazor-part-3.jpg" alt="Blazor Server Part 1" />
                <!-- 슬라이드 위 텍스트/버튼 영역 -->
                <div class="az-slide-content">
                    <h2>Blazor Server Part 1</h2>
                    <p>회사 홈페이지와 관리자 페이지를 Blazor로 만드는 실전 웹개발 과정</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4369&method=S&relation=PT001TB4369_BlazorServer" target="_blank">
                        강의 보기
                    </a>
                </div>
            </div>

            <!-- 두 번째 슬라이드 -->
            <div class="az-slide">
                <img src="~/images/az-carousel/blazor-part-2.jpg" alt="Blazor Server Part 2" />
                <div class="az-slide-content">
                    <h2>Blazor 게시판 프로젝트 Part 2</h2>
                    <p>공지사항, 자료실, 답변형 게시판을 Blazor로 직접 구현해보세요</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4370&method=S&relation=PT001TB4370_BlazorServerPart2" target="_blank">
                        강의 보기
                    </a>
                </div>
            </div>

            <!-- 세 번째 슬라이드 -->
            <div class="az-slide">
                <img src="~/images/az-carousel/blazor-part-1.jpg" alt="Blazor Server Part 3" />
                <div class="az-slide-content">
                    <h2>Blazor 실전 프로젝트 Part 3</h2>
                    <p>hawaso.com 사이트를 직접 구현하며 실무 핵심 기능을 익히는 강의</p>
                    <a href="http://www.devlec.com/?_pageVariable=courseDetail&code=PT001TB4371&method=S&relation=PT001TB4371_BlazorProject" target="_blank">
                        강의 보기
                    </a>
                </div>
            </div>

        </div>

        <!-- 이전 슬라이드로 이동하는 버튼 -->
        <div class="az-nav-arrow az-left az-prev-btn">
            <i class="fas fa-chevron-left"></i>
        </div>

        <!-- 다음 슬라이드로 이동하는 버튼 -->
        <div class="az-nav-arrow az-right az-next-btn">
            <i class="fas fa-chevron-right"></i>
        </div>

        <!-- 진행바(dot)와 일시정지 버튼이 스크립트에 의해 동적으로 생성되는 영역 -->
        <div class="az-progress-indicators"></div>

    </section>
</div>

@* 카루셀 동작을 담당하는 스크립트 로드 *@
<script src="/js/az-carousel.js"></script>
더 깊이 공부하고 싶다면
DevLec에서는 실무 중심의 C#, .NET, ASP.NET Core, Blazor, 데이터 액세스 강좌를 단계별로 제공합니다. 현재 수강 가능한 강좌 외에도 더 많은 과정이 준비되어 있습니다.
DevLec.com에서 자세한 커리큘럼을 확인해 보세요.
DevLec 공식 강의
C# Programming
C# 프로그래밍 입문
프로그래밍을 처음 시작하는 입문자를 위한 C# 기본기 완성 과정입니다.
ASP.NET Core 10.0
ASP.NET Core 10.0 시작하기 MVC Fundamentals Part 1 MVC Fundamentals Part 2
웹 애플리케이션의 구조와 MVC 패턴을 ASP.NET Core로 실습하며 익힐 수 있습니다.
Blazor Server
풀스택 웹개발자 과정 Part 1 풀스택 웹개발자 과정 Part 2 풀스택 웹개발자 과정 Part 3
실무에서 바로 활용 가능한 Blazor Server 기반 관리자·포털 프로젝트를 만들어 봅니다.
Data & APIs
Entity Framework Core 시작하기 ADO.NET Fundamentals Blazor Server Fundamentals Minimal APIs
데이터 액세스와 Web API를 함께 이해하면 실무 .NET 백엔드 개발에 큰 도움이 됩니다.
VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com