갤러리형 게시판

갤러리형 게시판

이미지 중심의 갤러리형 게시판을 구현하는 방법을 학습합니다.

기본 갤러리 레이아웃

그리드 형태의 목록

<!-- 갤러리형 리스트 -->
<div class="gallery-list">
    <ul class="gallery-items">
        <li loop="$document_list=>$no,$document" class="gallery-item">
            {@
                // 썸네일 가져오기
                $thumbnail = $document->getThumbnail(300, 200);
                if(!$thumbnail) {
                    $thumbnail = '/modules/board/skins/gallery/img/no-image.png';
                }
            }

            <a href="{getUrl('document_srl',$document->document_srl)}" class="item-link">
                <div class="thumbnail">
                    <img src="{$thumbnail}" alt="{$document->getTitle()}" />

                    <!-- 오버레이 정보 -->
                    <div class="overlay">
                        <span class="view-count">
                            <i class="xi-eye"></i> {$document->get('readed_count')}
                        </span>
                        <span class="comment-count" cond="$document->getCommentCount()">
                            <i class="xi-comment"></i> {$document->getCommentCount()}
                        </span>
                    </div>
                </div>

                <div class="item-info">
                    <h3 class="title">{$document->getTitle()}</h3>
                    <div class="meta">
                        <span class="author">{$document->getNickName()}</span>
                        <span class="date">{$document->getRegdate('Y.m.d')}</span>
                    </div>
                </div>
            </a>
        </li>
    </ul>
</div>

<!-- CSS -->
<style>
.gallery-list {
    margin: -10px;
}

.gallery-items {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
    padding: 0;
}

.gallery-item {
    width: 25%;
    padding: 10px;
    box-sizing: border-box;
}

@media (max-width: 1200px) {
    .gallery-item { width: 33.333%; }
}

@media (max-width: 768px) {
    .gallery-item { width: 50%; }
}

@media (max-width: 480px) {
    .gallery-item { width: 100%; }
}

.item-link {
    display: block;
    text-decoration: none;
    color: inherit;
}

.thumbnail {
    position: relative;
    overflow: hidden;
    padding-bottom: 66.666%; /* 3:2 비율 */
}

.thumbnail img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 0.3s;
}

.item-link:hover .thumbnail img {
    transform: scale(1.1);
}

.overlay {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    background: linear-gradient(transparent, rgba(0,0,0,0.7));
    color: white;
    padding: 10px;
    display: flex;
    justify-content: space-between;
}

.item-info {
    padding: 15px 10px;
}

.title {
    font-size: 16px;
    margin: 0 0 10px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.meta {
    font-size: 13px;
    color: #666;
}
</style>

이미지 필터링 기능

이미지가 있는 글만 표시

<!-- 이미지 필터 옵션 -->
<div class="filter-options">
    <label>
        <input type="checkbox" id="filter-has-image" checked="checked" />
        이미지가 있는 글만 보기
    </label>

    <label>
        <input type="checkbox" id="filter-has-video" />
        동영상이 있는 글만 보기
    </label>
</div>

<script>
jQuery(function($) {
    // 이미지 필터
    $('#filter-has-image').change(function() {
        filterGalleryItems();
    });

    $('#filter-has-video').change(function() {
        filterGalleryItems();
    });

    function filterGalleryItems() {
        var showImage = $('#filter-has-image').is(':checked');
        var showVideo = $('#filter-has-video').is(':checked');

        $('.gallery-item').each(function() {
            var $item = $(this);
            var hasImage = $item.find('.thumbnail img').attr('src').indexOf('no-image') === -1;
            var hasVideo = $item.hasClass('has-video');

            var show = true;
            if(showImage && !hasImage) show = false;
            if(showVideo && !hasVideo) show = false;

            $item.toggle(show);
        });
    }
});
</script>

라이트박스 연동

이미지 확대 보기

<!-- Photoswipe 연동 -->
<div class="gallery-list" id="gallery">
    <div loop="$document_list=>$no,$document" class="gallery-item">
        {@
            $thumbnail = $document->getThumbnail(300, 200);
            $original = '';

            // 원본 이미지 찾기
            if($document->hasUploadedFiles()) {
                $files = $document->getUploadedFiles();
                foreach($files as $file) {
                    if($file->direct_download == 'Y' && preg_match('/\.(jpg|jpeg|png|gif)$/i', $file->source_filename)) {
                        $original = $file->download_url;
                        break;
                    }
                }
            }
        }

        <a href="{$original ?: $thumbnail}" 
           data-size="{$file->width}x{$file->height}" 
           data-title="{$document->getTitle()}"
           class="gallery-link">
            <img src="{$thumbnail}" alt="{$document->getTitle()}" />
        </a>

        <div class="item-info">
            <h3>{$document->getTitle()}</h3>
        </div>
    </div>
</div>

<!-- PhotoSwipe 구조 -->
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
    <div class="pswp__bg"></div>
    <div class="pswp__scroll-wrap">
        <div class="pswp__container">
            <div class="pswp__item"></div>
            <div class="pswp__item"></div>
            <div class="pswp__item"></div>
        </div>
        <div class="pswp__ui pswp__ui--hidden">
            <!-- UI 요소들 -->
        </div>
    </div>
</div>

<script>
// PhotoSwipe 초기화
function initPhotoSwipe() {
    var $gallery = $('#gallery');

    $gallery.on('click', '.gallery-link', function(e) {
        e.preventDefault();

        var items = [];
        var index = 0;
        var clickedIndex = 0;

        $gallery.find('.gallery-link').each(function(i) {
            var $link = $(this);
            var size = $link.data('size').split('x');

            items.push({
                src: $link.attr('href'),
                w: parseInt(size[0], 10),
                h: parseInt(size[1], 10),
                title: $link.data('title')
            });

            if(this === e.currentTarget) {
                clickedIndex = i;
            }
        });

        var options = {
            index: clickedIndex,
            bgOpacity: 0.9,
            showHideOpacity: true
        };

        var gallery = new PhotoSwipe($('.pswp')[0], PhotoSwipeUI_Default, items, options);
        gallery.init();
    });
}

jQuery(function() {
    initPhotoSwipe();
});
</script>

무한 스크롤

자동 로딩 구현

<div class="gallery-list" id="gallery-container">
    <!-- 갤러리 아이템들 -->
</div>

<div class="loading-indicator" style="display:none;">
    <i class="xi-spinner xi-spin"></i> 로딩 중...
</div>

<script>
var currentPage = 1;
var isLoading = false;
var hasMore = true;

jQuery(function($) {
    // 스크롤 이벤트
    $(window).scroll(function() {
        if(isLoading || !hasMore) return;

        var scrollHeight = $(document).height();
        var scrollPosition = $(window).height() + $(window).scrollTop();

        if((scrollHeight - scrollPosition) < 200) {
            loadMoreItems();
        }
    });

    function loadMoreItems() {
        isLoading = true;
        $('.loading-indicator').show();

        var params = {
            module_srl: window.module_srl,
            page: currentPage + 1,
            list_count: 20
        };

        exec_xml('board', 'getBoardList', params, function(ret) {
            if(ret.data) {
                var html = '';

                $.each(ret.data, function(i, item) {
                    html += renderGalleryItem(item);
                });

                $('#gallery-container').append(html);
                currentPage++;

                if(ret.total_page <= currentPage) {
                    hasMore = false;
                }
            }

            isLoading = false;
            $('.loading-indicator').hide();
        });
    }

    function renderGalleryItem(item) {
        return '<div class="gallery-item">' +
               '<img src="' + item.thumbnail + '" />' +
               '<h3>' + item.title + '</h3>' +
               '</div>';
    }
});
</script>

이미지 레이지 로딩

성능 최적화

<!-- Lazy loading 적용 -->
<div class="gallery-item">
    <img class="lazy" 
         data-src="{$thumbnail}" 
         src="/modules/board/skins/gallery/img/placeholder.gif"
         alt="{$document->getTitle()}" />
    <noscript>
        <img src="{$thumbnail}" alt="{$document->getTitle()}" />
    </noscript>
</div>

<script>
// Intersection Observer를 이용한 lazy loading
document.addEventListener('DOMContentLoaded', function() {
    var lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));

    if('IntersectionObserver' in window) {
        var lazyImageObserver = new IntersectionObserver(function(entries, observer) {
            entries.forEach(function(entry) {
                if(entry.isIntersecting) {
                    var lazyImage = entry.target;
                    lazyImage.src = lazyImage.dataset.src;
                    lazyImage.classList.remove('lazy');
                    lazyImage.classList.add('loaded');
                    lazyImageObserver.unobserve(lazyImage);
                }
            });
        });

        lazyImages.forEach(function(lazyImage) {
            lazyImageObserver.observe(lazyImage);
        });
    } else {
        // 폴백: 스크롤 이벤트 사용
        var lazyLoad = function() {
            lazyImages.forEach(function(img) {
                if(isInViewport(img)) {
                    img.src = img.dataset.src;
                    img.classList.remove('lazy');
                }
            });
        };

        window.addEventListener('scroll', lazyLoad);
        window.addEventListener('resize', lazyLoad);
    }
});

function isInViewport(el) {
    var rect = el.getBoundingClientRect();
    return (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
}
</script>

필터 및 정렬

다양한 정렬 옵션

<!-- 정렬 옵션 -->
<div class="sort-options">
    <button class="sort-btn active" data-sort="newest">최신순</button>
    <button class="sort-btn" data-sort="popular">인기순</button>
    <button class="sort-btn" data-sort="commented">댓글순</button>
    <button class="sort-btn" data-sort="random">랜덤</button>
</div>

<!-- 카테고리 필터 -->
<div class="category-filter">
    <button class="cat-btn active" data-category="">전체</button>
    <button loop="$category_list=>$cat" class="cat-btn" data-category="{$cat->category_srl}">
        {$cat->title}
    </button>
</div>

<script>
jQuery(function($) {
    // 정렬
    $('.sort-btn').click(function() {
        $('.sort-btn').removeClass('active');
        $(this).addClass('active');

        var sort = $(this).data('sort');
        loadGallery({sort_index: getSortIndex(sort)});
    });

    // 카테고리 필터
    $('.cat-btn').click(function() {
        $('.cat-btn').removeClass('active');
        $(this).addClass('active');

        var category = $(this).data('category');
        loadGallery({category_srl: category});
    });

    function getSortIndex(sort) {
        var map = {
            'newest': 'regdate',
            'popular': 'readed_count',
            'commented': 'comment_count',
            'random': 'list_order'
        };
        return map[sort] || 'regdate';
    }

    function loadGallery(params) {
        $('#gallery-container').addClass('loading');

        params = $.extend({
            module_srl: window.module_srl,
            page: 1
        }, params);

        exec_xml('board', 'getBoardList', params, function(ret) {
            renderGallery(ret.data);
            $('#gallery-container').removeClass('loading');
        });
    }
});
</script>

모범 사례

  1. 이미지 최적화: 썸네일 크기 적절히 설정
  2. 레이지 로딩: 초기 로딩 속도 개선
  3. 반응형 디자인: 다양한 디바이스 대응
  4. 캐싱 활용: 이미지 캐싱으로 성능 향상
  5. 접근성: alt 텍스트 필수 제공

다음 단계

갤러리형 게시판을 구현했다면, 카테고리 관리를 학습하세요.